mirror of
https://github.com/openai/codex.git
synced 2026-05-12 07:12:37 +00:00
Compare commits
25 Commits
daniel/aut
...
jif/state2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d27d6bc9e | ||
|
|
29bfa77cc0 | ||
|
|
c8d65bbf5e | ||
|
|
5b910f1f05 | ||
|
|
af6304c641 | ||
|
|
b90eeabd74 | ||
|
|
f7d2f3e54d | ||
|
|
3fe3b6328b | ||
|
|
8144ddb3da | ||
|
|
9336f2b84b | ||
|
|
af37785bca | ||
|
|
594248f415 | ||
|
|
8227a5ba1b | ||
|
|
fdb8dadcae | ||
|
|
0f9a796617 | ||
|
|
c6e8671b2a | ||
|
|
b84a920067 | ||
|
|
6cd5309d91 | ||
|
|
664ee07540 | ||
|
|
51c465bddc | ||
|
|
e0fbc112c7 | ||
|
|
76ecbb3d8e | ||
|
|
2451b19d13 | ||
|
|
5c7d9e27b1 | ||
|
|
c93e77b68b |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -27,12 +27,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run all tasks using workspace filters
|
||||
# build_npm_package.py requires DotSlash when staging releases.
|
||||
- uses: facebook/install-dotslash@v2
|
||||
|
||||
- name: Ensure staging a release works.
|
||||
- name: Stage npm package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ./codex-cli/scripts/stage_release.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CODEX_VERSION=0.40.0
|
||||
PACK_OUTPUT="${RUNNER_TEMP}/codex-npm.tgz"
|
||||
python3 ./codex-cli/scripts/build_npm_package.py \
|
||||
--release-version "$CODEX_VERSION" \
|
||||
--pack-output "$PACK_OUTPUT"
|
||||
echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload staged npm package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: codex-npm-staging
|
||||
path: ${{ env.PACK_OUTPUT }}
|
||||
|
||||
- name: Ensure root README.md contains only ASCII and certain Unicode code points
|
||||
run: ./scripts/asciicheck.py README.md
|
||||
|
||||
50
.github/workflows/rust-release.yml
vendored
50
.github/workflows/rust-release.yml
vendored
@@ -173,6 +173,8 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.release_name.outputs.name }}
|
||||
tag: ${{ github.ref_name }}
|
||||
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
|
||||
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -193,21 +195,37 @@ jobs:
|
||||
version="${GITHUB_REF_NAME#rust-v}"
|
||||
echo "name=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine npm publish settings
|
||||
id: npm_publish_settings
|
||||
env:
|
||||
VERSION: ${{ steps.release_name.outputs.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${VERSION}"
|
||||
|
||||
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_tag=" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_tag=alpha" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# build_npm_package.py requires DotSlash when staging releases.
|
||||
- uses: facebook/install-dotslash@v2
|
||||
- name: Stage npm package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_DIR="${RUNNER_TEMP}/npm-stage"
|
||||
python3 codex-cli/scripts/stage_rust_release.py \
|
||||
./codex-cli/scripts/build_npm_package.py \
|
||||
--release-version "${{ steps.release_name.outputs.name }}" \
|
||||
--tmp "${TMP_DIR}"
|
||||
mkdir -p dist/npm
|
||||
# Produce an npm-ready tarball using `npm pack` and store it in dist/npm.
|
||||
# We then rename it to a stable name used by our publishing script.
|
||||
(cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
|
||||
mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \
|
||||
"${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
--staging-dir "${TMP_DIR}" \
|
||||
--pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -230,8 +248,8 @@ jobs:
|
||||
# July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/
|
||||
# npm docs: https://docs.npmjs.com/trusted-publishers
|
||||
publish-npm:
|
||||
# Skip this step for pre-releases (alpha/beta).
|
||||
if: ${{ !contains(needs.release.outputs.version, '-') }}
|
||||
# Publish to npm for stable releases and alpha pre-releases with numeric suffixes.
|
||||
if: ${{ needs.release.outputs.should_publish_npm == 'true' }}
|
||||
name: publish-npm
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -266,7 +284,17 @@ jobs:
|
||||
|
||||
# No NODE_AUTH_TOKEN needed because we use OIDC.
|
||||
- name: Publish to npm
|
||||
run: npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ needs.release.outputs.version }}.tgz"
|
||||
env:
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
NPM_TAG: ${{ needs.release.outputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_args=()
|
||||
if [[ -n "${NPM_TAG}" ]]; then
|
||||
tag_args+=(--tag "${NPM_TAG}")
|
||||
fi
|
||||
|
||||
npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}"
|
||||
|
||||
update-branch:
|
||||
name: Update latest-alpha-cli branch
|
||||
|
||||
8
codex-cli/.gitignore
vendored
8
codex-cli/.gitignore
vendored
@@ -1,7 +1 @@
|
||||
# Added by ./scripts/install_native_deps.sh
|
||||
/bin/codex-aarch64-apple-darwin
|
||||
/bin/codex-aarch64-unknown-linux-musl
|
||||
/bin/codex-linux-sandbox-arm64
|
||||
/bin/codex-linux-sandbox-x64
|
||||
/bin/codex-x86_64-apple-darwin
|
||||
/bin/codex-x86_64-unknown-linux-musl
|
||||
/vendor/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Unified entry point for the Codex CLI.
|
||||
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -40,10 +41,10 @@ switch (platform) {
|
||||
case "win32":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
targetTriple = "x86_64-pc-windows-msvc.exe";
|
||||
targetTriple = "x86_64-pc-windows-msvc";
|
||||
break;
|
||||
case "arm64":
|
||||
targetTriple = "aarch64-pc-windows-msvc.exe";
|
||||
targetTriple = "aarch64-pc-windows-msvc";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -57,7 +58,10 @@ if (!targetTriple) {
|
||||
throw new Error(`Unsupported platform: ${platform} (${arch})`);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
|
||||
const vendorRoot = path.join(__dirname, "..", "vendor");
|
||||
const archRoot = path.join(vendorRoot, targetTriple);
|
||||
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
|
||||
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
|
||||
|
||||
// Use an asynchronous spawn instead of spawnSync so that Node is able to
|
||||
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
|
||||
@@ -66,23 +70,6 @@ const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
|
||||
// receives a fatal signal, both processes exit in a predictable manner.
|
||||
const { spawn } = await import("child_process");
|
||||
|
||||
async function tryImport(moduleName) {
|
||||
try {
|
||||
// eslint-disable-next-line node/no-unsupported-features/es-syntax
|
||||
return await import(moduleName);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRgDir() {
|
||||
const ripgrep = await tryImport("@vscode/ripgrep");
|
||||
if (!ripgrep?.rgPath) {
|
||||
return null;
|
||||
}
|
||||
return path.dirname(ripgrep.rgPath);
|
||||
}
|
||||
|
||||
function getUpdatedPath(newDirs) {
|
||||
const pathSep = process.platform === "win32" ? ";" : ":";
|
||||
const existingPath = process.env.PATH || "";
|
||||
@@ -94,9 +81,9 @@ function getUpdatedPath(newDirs) {
|
||||
}
|
||||
|
||||
const additionalDirs = [];
|
||||
const rgDir = await resolveRgDir();
|
||||
if (rgDir) {
|
||||
additionalDirs.push(rgDir);
|
||||
const pathDir = path.join(archRoot, "path");
|
||||
if (existsSync(pathDir)) {
|
||||
additionalDirs.push(pathDir);
|
||||
}
|
||||
const updatedPath = getUpdatedPath(additionalDirs);
|
||||
|
||||
|
||||
79
codex-cli/bin/rg
Executable file
79
codex-cli/bin/rg
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env dotslash
|
||||
|
||||
{
|
||||
"name": "rg",
|
||||
"platforms": {
|
||||
"macos-aarch64": {
|
||||
"size": 1787248,
|
||||
"hash": "blake3",
|
||||
"digest": "8d9942032585ea8ee805937634238d9aee7b210069f4703c88fbe568e26fb78a",
|
||||
"format": "tar.gz",
|
||||
"path": "ripgrep-14.1.1-aarch64-apple-darwin/rg",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"size": 2047405,
|
||||
"hash": "blake3",
|
||||
"digest": "0b670b8fa0a3df2762af2fc82cc4932f684ca4c02dbd1260d4f3133fd4b2a515",
|
||||
"format": "tar.gz",
|
||||
"path": "ripgrep-14.1.1-aarch64-unknown-linux-gnu/rg",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"macos-x86_64": {
|
||||
"size": 2082672,
|
||||
"hash": "blake3",
|
||||
"digest": "e9b862fc8da3127f92791f0ff6a799504154ca9d36c98bf3e60a81c6b1f7289e",
|
||||
"format": "tar.gz",
|
||||
"path": "ripgrep-14.1.1-x86_64-apple-darwin/rg",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-apple-darwin.tar.gz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"size": 2566310,
|
||||
"hash": "blake3",
|
||||
"digest": "f73cca4e54d78c31f832c7f6e2c0b4db8b04fa3eaa747915727d570893dbee76",
|
||||
"format": "tar.gz",
|
||||
"path": "ripgrep-14.1.1-x86_64-unknown-linux-musl/rg",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz"
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"size": 2058893,
|
||||
"hash": "blake3",
|
||||
"digest": "a8ce1a6fed4f8093ee997e57f33254e94b2cd18e26358b09db599c89882eadbd",
|
||||
"format": "zip",
|
||||
"path": "ripgrep-14.1.1-x86_64-pc-windows-msvc/rg.exe",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"size": 1667740,
|
||||
"hash": "blake3",
|
||||
"digest": "47b971a8c4fca1d23a4e7c19bd4d88465ebc395598458133139406d3bf85f3fa",
|
||||
"format": "zip",
|
||||
"path": "rg.exe",
|
||||
"providers": [
|
||||
{
|
||||
"url": "https://github.com/microsoft/ripgrep-prebuilt/releases/download/v13.0.0-13/ripgrep-v13.0.0-13-aarch64-pc-windows-msvc.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
101
codex-cli/package-lock.json
generated
101
codex-cli/package-lock.json
generated
@@ -2,118 +2,17 @@
|
||||
"name": "@openai/codex",
|
||||
"version": "0.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.0.0-dev",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.15.14"
|
||||
},
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/ripgrep": {
|
||||
"version": "1.15.14",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz",
|
||||
"integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"proxy-from-env": "^1.1.0",
|
||||
"yauzl": "^2.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,11 @@
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
"vendor"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openai/codex.git",
|
||||
"directory": "codex-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.15.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,7 @@ Run the following:
|
||||
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
|
||||
|
||||
```bash
|
||||
./codex-cli/scripts/stage_rust_release.py --release-version 0.6.0
|
||||
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
|
||||
```
|
||||
|
||||
Note this will create `./codex-cli/vendor/` as a side-effect.
|
||||
|
||||
294
codex-cli/scripts/build_npm_package.py
Executable file
294
codex-cli/scripts/build_npm_package.py
Executable file
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage and optionally package the @openai/codex npm module."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
REPO_ROOT = CODEX_CLI_ROOT.parent
|
||||
GITHUB_REPO = "openai/codex"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
help="Version number to write to package.json inside the staged package.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-version",
|
||||
help=(
|
||||
"Version to stage for npm release. When provided, the script also resolves the "
|
||||
"matching rust-release workflow unless --workflow-url is supplied."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workflow-url",
|
||||
help="Optional GitHub Actions workflow run URL used to download native binaries.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--staging-dir",
|
||||
type=Path,
|
||||
help=(
|
||||
"Directory to stage the package contents. Defaults to a new temporary directory "
|
||||
"if omitted. The directory must be empty when provided."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmp",
|
||||
dest="staging_dir",
|
||||
type=Path,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pack-output",
|
||||
type=Path,
|
||||
help="Path where the generated npm tarball should be written.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
version = args.version
|
||||
release_version = args.release_version
|
||||
if release_version:
|
||||
if version and version != release_version:
|
||||
raise RuntimeError("--version and --release-version must match when both are provided.")
|
||||
version = release_version
|
||||
|
||||
if not version:
|
||||
raise RuntimeError("Must specify --version or --release-version.")
|
||||
|
||||
staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
|
||||
|
||||
try:
|
||||
stage_sources(staging_dir, version)
|
||||
|
||||
workflow_url = args.workflow_url
|
||||
resolved_head_sha: str | None = None
|
||||
if not workflow_url:
|
||||
if release_version:
|
||||
workflow = resolve_release_workflow(version)
|
||||
workflow_url = workflow["url"]
|
||||
resolved_head_sha = workflow.get("headSha")
|
||||
else:
|
||||
workflow_url = resolve_latest_alpha_workflow_url()
|
||||
elif release_version:
|
||||
try:
|
||||
workflow = resolve_release_workflow(version)
|
||||
resolved_head_sha = workflow.get("headSha")
|
||||
except Exception:
|
||||
resolved_head_sha = None
|
||||
|
||||
if release_version and resolved_head_sha:
|
||||
print(f"should `git checkout {resolved_head_sha}`")
|
||||
|
||||
if not workflow_url:
|
||||
raise RuntimeError("Unable to determine workflow URL for native binaries.")
|
||||
|
||||
install_native_binaries(staging_dir, workflow_url)
|
||||
|
||||
if release_version:
|
||||
staging_dir_str = str(staging_dir)
|
||||
print(
|
||||
f"Staged version {version} for release in {staging_dir_str}\n\n"
|
||||
"Verify the CLI:\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --version\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --help\n\n"
|
||||
)
|
||||
else:
|
||||
print(f"Staged package in {staging_dir}")
|
||||
|
||||
if args.pack_output is not None:
|
||||
output_path = run_npm_pack(staging_dir, args.pack_output)
|
||||
print(f"npm pack output written to {output_path}")
|
||||
finally:
|
||||
if created_temp:
|
||||
# Preserve the staging directory for further inspection.
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]:
|
||||
if staging_dir is not None:
|
||||
staging_dir = staging_dir.resolve()
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
if any(staging_dir.iterdir()):
|
||||
raise RuntimeError(f"Staging directory {staging_dir} is not empty.")
|
||||
return staging_dir, False
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="codex-npm-stage-"))
|
||||
return temp_dir, True
|
||||
|
||||
|
||||
def stage_sources(staging_dir: Path, version: str) -> None:
|
||||
bin_dir = staging_dir / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
|
||||
rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
if rg_manifest.exists():
|
||||
shutil.copy2(rg_manifest, bin_dir / "rg")
|
||||
|
||||
readme_src = REPO_ROOT / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, staging_dir / "README.md")
|
||||
|
||||
with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh:
|
||||
package_json = json.load(fh)
|
||||
package_json["version"] = version
|
||||
|
||||
with open(staging_dir / "package.json", "w", encoding="utf-8") as out:
|
||||
json.dump(package_json, out, indent=2)
|
||||
out.write("\n")
|
||||
|
||||
|
||||
def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
|
||||
cmd = ["./scripts/install_native_deps.py"]
|
||||
if workflow_url:
|
||||
cmd.extend(["--workflow-url", workflow_url])
|
||||
cmd.append(str(staging_dir))
|
||||
subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT)
|
||||
|
||||
|
||||
def resolve_latest_alpha_workflow_url() -> str:
|
||||
version = determine_latest_alpha_version()
|
||||
workflow_url = fetch_workflow_url_for_version(version)
|
||||
if not workflow_url:
|
||||
raise RuntimeError(f"Unable to locate workflow for version {version}.")
|
||||
return workflow_url
|
||||
|
||||
|
||||
def determine_latest_alpha_version() -> str:
|
||||
releases = list_releases()
|
||||
best_key: tuple[int, int, int, int] | None = None
|
||||
best_version: str | None = None
|
||||
pattern = re.compile(r"^rust-v(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$")
|
||||
for release in releases:
|
||||
tag = release.get("tag_name", "")
|
||||
match = pattern.match(tag)
|
||||
if not match:
|
||||
continue
|
||||
key = tuple(int(match.group(i)) for i in range(1, 5))
|
||||
if best_key is None or key > best_key:
|
||||
best_key = key
|
||||
best_version = (
|
||||
f"{match.group(1)}.{match.group(2)}.{match.group(3)}-alpha.{match.group(4)}"
|
||||
)
|
||||
|
||||
if best_version is None:
|
||||
raise RuntimeError("No alpha releases found when resolving workflow URL.")
|
||||
return best_version
|
||||
|
||||
|
||||
def list_releases() -> list[dict]:
|
||||
stdout = subprocess.check_output(
|
||||
["gh", "api", f"/repos/{GITHUB_REPO}/releases?per_page=100"],
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
releases = json.loads(stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Unable to parse releases JSON.") from exc
|
||||
if not isinstance(releases, list):
|
||||
raise RuntimeError("Unexpected response when listing releases.")
|
||||
return releases
|
||||
|
||||
|
||||
def fetch_workflow_url_for_version(version: str) -> str | None:
|
||||
ref = f"rust-v{version}"
|
||||
stdout = subprocess.check_output(
|
||||
[
|
||||
"gh",
|
||||
"run",
|
||||
"list",
|
||||
"--branch",
|
||||
ref,
|
||||
"--limit",
|
||||
"20",
|
||||
"--json",
|
||||
"workflowName,url",
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
|
||||
try:
|
||||
runs = json.loads(stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Unable to parse workflow run listing.") from exc
|
||||
|
||||
for run in runs:
|
||||
if run.get("workflowName") == "rust-release":
|
||||
url = run.get("url")
|
||||
if url:
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def resolve_release_workflow(version: str) -> dict:
|
||||
stdout = subprocess.check_output(
|
||||
[
|
||||
"gh",
|
||||
"run",
|
||||
"list",
|
||||
"--branch",
|
||||
f"rust-v{version}",
|
||||
"--json",
|
||||
"workflowName,url,headSha",
|
||||
"--jq",
|
||||
'first(.[] | select(.workflowName == "rust-release"))',
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
workflow = json.loads(stdout)
|
||||
if not workflow:
|
||||
raise RuntimeError(f"Unable to find rust-release workflow for version {version}.")
|
||||
return workflow
|
||||
|
||||
|
||||
def run_npm_pack(staging_dir: Path, output_path: Path) -> Path:
|
||||
output_path = output_path.resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str:
|
||||
pack_dir = Path(pack_dir_str)
|
||||
stdout = subprocess.check_output(
|
||||
["npm", "pack", "--json", "--pack-destination", str(pack_dir)],
|
||||
cwd=staging_dir,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
pack_output = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("Failed to parse npm pack output.") from exc
|
||||
|
||||
if not pack_output:
|
||||
raise RuntimeError("npm pack did not produce an output tarball.")
|
||||
|
||||
tarball_name = pack_output[0].get("filename") or pack_output[0].get("name")
|
||||
if not tarball_name:
|
||||
raise RuntimeError("Unable to determine npm pack output filename.")
|
||||
|
||||
tarball_path = pack_dir / tarball_name
|
||||
if not tarball_path.exists():
|
||||
raise RuntimeError(f"Expected npm pack output not found: {tarball_path}")
|
||||
|
||||
shutil.move(str(tarball_path), output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
318
codex-cli/scripts/install_native_deps.py
Executable file
318
codex-cli/scripts/install_native_deps.py
Executable file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install Codex native binaries (Rust CLI plus ripgrep helpers)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
|
||||
VENDOR_DIR_NAME = "vendor"
|
||||
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
CODEX_TARGETS = (
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc",
|
||||
)
|
||||
|
||||
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
|
||||
("x86_64-unknown-linux-musl", "linux-x86_64"),
|
||||
("aarch64-unknown-linux-musl", "linux-aarch64"),
|
||||
("x86_64-apple-darwin", "macos-x86_64"),
|
||||
("aarch64-apple-darwin", "macos-aarch64"),
|
||||
("x86_64-pc-windows-msvc", "windows-x86_64"),
|
||||
("aarch64-pc-windows-msvc", "windows-aarch64"),
|
||||
]
|
||||
RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS}
|
||||
DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Install native Codex binaries.")
|
||||
parser.add_argument(
|
||||
"--workflow-url",
|
||||
help=(
|
||||
"GitHub Actions workflow URL that produced the artifacts. Defaults to a "
|
||||
"known good run when omitted."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"root",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
help=(
|
||||
"Directory containing package.json for the staged package. If omitted, the "
|
||||
"repository checkout is used."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve()
|
||||
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
|
||||
if not workflow_url:
|
||||
workflow_url = DEFAULT_WORKFLOW_URL
|
||||
|
||||
workflow_id = workflow_url.rstrip("/").split("/")[-1]
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
|
||||
artifacts_dir = Path(artifacts_dir_str)
|
||||
_download_artifacts(workflow_id, artifacts_dir)
|
||||
install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS)
|
||||
|
||||
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
|
||||
|
||||
print(f"Installed native dependencies into {vendor_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def fetch_rg(
|
||||
vendor_dir: Path,
|
||||
targets: Sequence[str] | None = None,
|
||||
*,
|
||||
manifest_path: Path,
|
||||
) -> list[Path]:
|
||||
"""Download ripgrep binaries described by the DotSlash manifest."""
|
||||
|
||||
if targets is None:
|
||||
targets = DEFAULT_RG_TARGETS
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}")
|
||||
|
||||
manifest = _load_manifest(manifest_path)
|
||||
platforms = manifest.get("platforms", {})
|
||||
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
targets = list(targets)
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
task_configs: list[tuple[str, str, dict]] = []
|
||||
for target in targets:
|
||||
platform_key = RG_TARGET_TO_PLATFORM.get(target)
|
||||
if platform_key is None:
|
||||
raise ValueError(f"Unsupported ripgrep target '{target}'.")
|
||||
|
||||
platform_info = platforms.get(platform_key)
|
||||
if platform_info is None:
|
||||
raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.")
|
||||
|
||||
task_configs.append((target, platform_key, platform_info))
|
||||
|
||||
results: dict[str, Path] = {}
|
||||
max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1)))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(
|
||||
_fetch_single_rg,
|
||||
vendor_dir,
|
||||
target,
|
||||
platform_key,
|
||||
platform_info,
|
||||
manifest_path,
|
||||
): target
|
||||
for target, platform_key, platform_info in task_configs
|
||||
}
|
||||
|
||||
for future in as_completed(future_map):
|
||||
target = future_map[future]
|
||||
results[target] = future.result()
|
||||
|
||||
return [results[target] for target in targets]
|
||||
|
||||
|
||||
def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
|
||||
cmd = [
|
||||
"gh",
|
||||
"run",
|
||||
"download",
|
||||
"--dir",
|
||||
str(dest_dir),
|
||||
"--repo",
|
||||
"openai/codex",
|
||||
workflow_id,
|
||||
]
|
||||
subprocess.check_call(cmd)
|
||||
|
||||
|
||||
def install_codex_binaries(
|
||||
artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str]
|
||||
) -> list[Path]:
|
||||
targets = list(targets)
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
results: dict[str, Path] = {}
|
||||
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target
|
||||
for target in targets
|
||||
}
|
||||
|
||||
for future in as_completed(future_map):
|
||||
target = future_map[future]
|
||||
results[target] = future.result()
|
||||
|
||||
return [results[target] for target in targets]
|
||||
|
||||
|
||||
def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path:
|
||||
artifact_subdir = artifacts_dir / target
|
||||
archive_name = _archive_name_for_target(target)
|
||||
archive_path = artifact_subdir / archive_name
|
||||
if not archive_path.exists():
|
||||
raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
|
||||
|
||||
dest_dir = vendor_dir / target / "codex"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
binary_name = "codex.exe" if "windows" in target else "codex"
|
||||
dest = dest_dir / binary_name
|
||||
dest.unlink(missing_ok=True)
|
||||
extract_archive(archive_path, "zst", None, dest)
|
||||
if "windows" not in target:
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _archive_name_for_target(target: str) -> str:
|
||||
if "windows" in target:
|
||||
return f"codex-{target}.exe.zst"
|
||||
return f"codex-{target}.zst"
|
||||
|
||||
|
||||
def _fetch_single_rg(
|
||||
vendor_dir: Path,
|
||||
target: str,
|
||||
platform_key: str,
|
||||
platform_info: dict,
|
||||
manifest_path: Path,
|
||||
) -> Path:
|
||||
providers = platform_info.get("providers", [])
|
||||
if not providers:
|
||||
raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.")
|
||||
|
||||
url = providers[0]["url"]
|
||||
archive_format = platform_info.get("format", "zst")
|
||||
archive_member = platform_info.get("path")
|
||||
|
||||
dest_dir = vendor_dir / target / "path"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
is_windows = platform_key.startswith("win")
|
||||
binary_name = "rg.exe" if is_windows else "rg"
|
||||
dest = dest_dir / binary_name
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir_str:
|
||||
tmp_dir = Path(tmp_dir_str)
|
||||
archive_filename = os.path.basename(urlparse(url).path)
|
||||
download_path = tmp_dir / archive_filename
|
||||
_download_file(url, download_path)
|
||||
|
||||
dest.unlink(missing_ok=True)
|
||||
extract_archive(download_path, archive_format, archive_member, dest)
|
||||
|
||||
if not is_windows:
|
||||
dest.chmod(0o755)
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with urlopen(url) as response, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(response, out)
|
||||
|
||||
|
||||
def extract_archive(
|
||||
archive_path: Path,
|
||||
archive_format: str,
|
||||
archive_member: str | None,
|
||||
dest: Path,
|
||||
) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if archive_format == "zst":
|
||||
output_path = archive_path.parent / dest.name
|
||||
subprocess.check_call(
|
||||
["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]
|
||||
)
|
||||
shutil.move(str(output_path), dest)
|
||||
return
|
||||
|
||||
if archive_format == "tar.gz":
|
||||
if not archive_member:
|
||||
raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.")
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
try:
|
||||
member = tar.getmember(archive_member)
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f"Entry '{archive_member}' not found in archive {archive_path}."
|
||||
) from exc
|
||||
tar.extract(member, path=archive_path.parent, filter="data")
|
||||
extracted = archive_path.parent / archive_member
|
||||
shutil.move(str(extracted), dest)
|
||||
return
|
||||
|
||||
if archive_format == "zip":
|
||||
if not archive_member:
|
||||
raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
try:
|
||||
with archive.open(archive_member) as src, open(dest, "wb") as out:
|
||||
shutil.copyfileobj(src, out)
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f"Entry '{archive_member}' not found in archive {archive_path}."
|
||||
) from exc
|
||||
return
|
||||
|
||||
raise RuntimeError(f"Unsupported archive format '{archive_format}'.")
|
||||
|
||||
|
||||
def _load_manifest(manifest_path: Path) -> dict:
|
||||
cmd = ["dotslash", "--", "parse", str(manifest_path)]
|
||||
stdout = subprocess.check_output(cmd, text=True)
|
||||
try:
|
||||
manifest = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
raise RuntimeError(
|
||||
f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}"
|
||||
)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Install native runtime dependencies for codex-cli.
|
||||
#
|
||||
# Usage
|
||||
# install_native_deps.sh [--workflow-url URL] [CODEX_CLI_ROOT]
|
||||
#
|
||||
# The optional RELEASE_ROOT is the path that contains package.json. Omitting
|
||||
# it installs the binaries into the repository's own bin/ folder to support
|
||||
# local development.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ------------------
|
||||
# Parse arguments
|
||||
# ------------------
|
||||
|
||||
CODEX_CLI_ROOT=""
|
||||
|
||||
# Until we start publishing stable GitHub releases, we have to grab the binaries
|
||||
# from the GitHub Action that created them. Update the URL below to point to the
|
||||
# appropriate workflow run:
|
||||
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/17417194663" # rust-v0.28.0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--workflow-url)
|
||||
shift || { echo "--workflow-url requires an argument"; exit 1; }
|
||||
if [ -n "$1" ]; then
|
||||
WORKFLOW_URL="$1"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$CODEX_CLI_ROOT" ]]; then
|
||||
CODEX_CLI_ROOT="$1"
|
||||
else
|
||||
echo "Unexpected argument: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Determine where the binaries should be installed.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$CODEX_CLI_ROOT" ]; then
|
||||
# The caller supplied a release root directory.
|
||||
BIN_DIR="$CODEX_CLI_ROOT/bin"
|
||||
else
|
||||
# No argument; fall back to the repo’s own bin directory.
|
||||
# Resolve the path of this script, then walk up to the repo root.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BIN_DIR="$CODEX_CLI_ROOT/bin"
|
||||
fi
|
||||
|
||||
# Make sure the destination directory exists.
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Download and decompress the artifacts from the GitHub Actions workflow.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
WORKFLOW_ID="${WORKFLOW_URL##*/}"
|
||||
|
||||
ARTIFACTS_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$ARTIFACTS_DIR"' EXIT
|
||||
|
||||
# NB: The GitHub CLI `gh` must be installed and authenticated.
|
||||
gh run download --dir "$ARTIFACTS_DIR" --repo openai/codex "$WORKFLOW_ID"
|
||||
|
||||
# x64 Linux
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-x86_64-unknown-linux-musl.zst" \
|
||||
-o "$BIN_DIR/codex-x86_64-unknown-linux-musl"
|
||||
# ARM64 Linux
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-musl/codex-aarch64-unknown-linux-musl.zst" \
|
||||
-o "$BIN_DIR/codex-aarch64-unknown-linux-musl"
|
||||
# x64 macOS
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-apple-darwin/codex-x86_64-apple-darwin.zst" \
|
||||
-o "$BIN_DIR/codex-x86_64-apple-darwin"
|
||||
# ARM64 macOS
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-apple-darwin/codex-aarch64-apple-darwin.zst" \
|
||||
-o "$BIN_DIR/codex-aarch64-apple-darwin"
|
||||
# x64 Windows
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-pc-windows-msvc/codex-x86_64-pc-windows-msvc.exe.zst" \
|
||||
-o "$BIN_DIR/codex-x86_64-pc-windows-msvc.exe"
|
||||
# ARM64 Windows
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-pc-windows-msvc/codex-aarch64-pc-windows-msvc.exe.zst" \
|
||||
-o "$BIN_DIR/codex-aarch64-pc-windows-msvc.exe"
|
||||
|
||||
echo "Installed native dependencies into $BIN_DIR"
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# stage_release.sh
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stages an npm release for @openai/codex.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# --tmp <dir> : Use <dir> instead of a freshly created temp directory.
|
||||
# -h|--help : Print usage.
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Helper - usage / flag parsing
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [--tmp DIR] [--version VERSION]
|
||||
|
||||
Options
|
||||
--tmp DIR Use DIR to stage the release (defaults to a fresh mktemp dir)
|
||||
--version Specify the version to release (defaults to a timestamp-based version)
|
||||
-h, --help Show this help
|
||||
|
||||
Legacy positional argument: the first non-flag argument is still interpreted
|
||||
as the temporary directory (for backwards compatibility) but is deprecated.
|
||||
EOF
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
TMPDIR=""
|
||||
# Default to a timestamp-based version (keep same scheme as before)
|
||||
VERSION="$(printf '0.1.%d' "$(date +%y%m%d%H%M)")"
|
||||
WORKFLOW_URL=""
|
||||
|
||||
# Manual flag parser - Bash getopts does not handle GNU long options well.
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tmp)
|
||||
shift || { echo "--tmp requires an argument"; usage 1; }
|
||||
TMPDIR="$1"
|
||||
;;
|
||||
--tmp=*)
|
||||
TMPDIR="${1#*=}"
|
||||
;;
|
||||
--version)
|
||||
shift || { echo "--version requires an argument"; usage 1; }
|
||||
VERSION="$1"
|
||||
;;
|
||||
--workflow-url)
|
||||
shift || { echo "--workflow-url requires an argument"; exit 1; }
|
||||
WORKFLOW_URL="$1"
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
--*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage 1
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected extra argument: $1" >&2
|
||||
usage 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Fallback when the caller did not specify a directory.
|
||||
# If no directory was specified create a fresh temporary one.
|
||||
if [[ -z "$TMPDIR" ]]; then
|
||||
TMPDIR="$(mktemp -d)"
|
||||
fi
|
||||
|
||||
# Ensure the directory exists, then resolve to an absolute path.
|
||||
mkdir -p "$TMPDIR"
|
||||
TMPDIR="$(cd "$TMPDIR" && pwd)"
|
||||
|
||||
# Main build logic
|
||||
|
||||
echo "Staging release in $TMPDIR"
|
||||
|
||||
# The script lives in codex-cli/scripts/ - change into codex-cli root so that
|
||||
# relative paths keep working.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
pushd "$CODEX_CLI_ROOT" >/dev/null
|
||||
|
||||
# 1. Build the JS artifacts ---------------------------------------------------
|
||||
|
||||
# Paths inside the staged package
|
||||
mkdir -p "$TMPDIR/bin"
|
||||
|
||||
cp -r bin/codex.js "$TMPDIR/bin/codex.js"
|
||||
cp ../README.md "$TMPDIR" || true # README is one level up - ignore if missing
|
||||
|
||||
# Modify package.json - bump version and optionally add the native directory to
|
||||
# the files array so that the binaries are published to npm.
|
||||
|
||||
jq --arg version "$VERSION" \
|
||||
'.version = $version' \
|
||||
package.json > "$TMPDIR/package.json"
|
||||
|
||||
# 2. Native runtime deps (sandbox plus optional Rust binaries)
|
||||
|
||||
./scripts/install_native_deps.sh --workflow-url "$WORKFLOW_URL" "$TMPDIR"
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
echo "Staged version $VERSION for release in $TMPDIR"
|
||||
|
||||
echo "Verify the CLI:"
|
||||
echo " node ${TMPDIR}/bin/codex.js --version"
|
||||
echo " node ${TMPDIR}/bin/codex.js --help"
|
||||
|
||||
# Print final hint for convenience
|
||||
echo "Next: cd \"$TMPDIR\" && npm publish"
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""Stage a release for the npm module.
|
||||
|
||||
Run this after the GitHub Release has been created and use
|
||||
`--release-version` to specify the version to release.
|
||||
|
||||
Optionally pass `--tmp` to control the temporary staging directory that will be
|
||||
forwarded to stage_release.sh.
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-version", required=True, help="Version to release, e.g., 0.3.0"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tmp",
|
||||
help="Optional path to stage the npm package; forwarded to stage_release.sh",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
version = args.release_version
|
||||
|
||||
gh_run = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"run",
|
||||
"list",
|
||||
"--branch",
|
||||
f"rust-v{version}",
|
||||
"--json",
|
||||
"workflowName,url,headSha",
|
||||
"--jq",
|
||||
'first(.[] | select(.workflowName == "rust-release"))',
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
gh_run.check_returncode()
|
||||
workflow = json.loads(gh_run.stdout)
|
||||
sha = workflow["headSha"]
|
||||
|
||||
print(f"should `git checkout {sha}`")
|
||||
|
||||
current_dir = Path(__file__).parent.resolve()
|
||||
cmd = [
|
||||
str(current_dir / "stage_release.sh"),
|
||||
"--version",
|
||||
version,
|
||||
"--workflow-url",
|
||||
workflow["url"],
|
||||
]
|
||||
if args.tmp:
|
||||
cmd.extend(["--tmp", args.tmp])
|
||||
|
||||
stage_release = subprocess.run(cmd)
|
||||
stage_release.check_returncode()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
75
codex-rs/Cargo.lock
generated
75
codex-rs/Cargo.lock
generated
@@ -316,6 +316,17 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -778,6 +789,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git-tooling"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
"thiserror 2.0.16",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-linux-sandbox"
|
||||
version = "0.0.0"
|
||||
@@ -795,6 +816,7 @@ dependencies = [
|
||||
name = "codex-login"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"codex-core",
|
||||
@@ -875,6 +897,7 @@ dependencies = [
|
||||
name = "codex-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
@@ -919,6 +942,7 @@ dependencies = [
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-file-search",
|
||||
"codex-git-tooling",
|
||||
"codex-login",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
@@ -959,6 +983,16 @@ dependencies = [
|
||||
"vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-readiness"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"thiserror 2.0.16",
|
||||
"time",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.5"
|
||||
@@ -1076,6 +1110,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
name = "core_test_support"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-core",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -1253,12 +1288,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2658,9 +2693,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
version = "0.4.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
@@ -3901,9 +3936,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.224"
|
||||
version = "1.0.226"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b"
|
||||
checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -3911,18 +3946,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.224"
|
||||
version = "1.0.226"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab"
|
||||
checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.224"
|
||||
version = "1.0.226"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0"
|
||||
checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4425,15 +4460,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.20.0"
|
||||
version = "3.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
|
||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4557,9 +4592,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.41"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
@@ -4574,15 +4609,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.22"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"exec",
|
||||
"execpolicy",
|
||||
"file-search",
|
||||
"git-tooling",
|
||||
"linux-sandbox",
|
||||
"login",
|
||||
"mcp-client",
|
||||
@@ -18,6 +19,7 @@ members = [
|
||||
"protocol",
|
||||
"protocol-ts",
|
||||
"tui",
|
||||
"utils/readiness",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -39,6 +41,7 @@ codex-common = { path = "common" }
|
||||
codex-core = { path = "core" }
|
||||
codex-exec = { path = "exec" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
codex-git-tooling = { path = "git-tooling" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-login = { path = "login" }
|
||||
codex-mcp-client = { path = "mcp-client" }
|
||||
@@ -47,6 +50,7 @@ codex-ollama = { path = "ollama" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-protocol-ts = { path = "protocol-ts" }
|
||||
codex-tui = { path = "tui" }
|
||||
codex-utils-readiness = { path = "utils/readiness" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
mcp-types = { path = "mcp-types" }
|
||||
mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
@@ -60,6 +64,7 @@ askama = "0.12"
|
||||
assert_cmd = "2"
|
||||
async-channel = "2.3.1"
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.10.1"
|
||||
chrono = "0.4.40"
|
||||
@@ -118,7 +123,7 @@ strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
supports-color = "3.0.2"
|
||||
sys-locale = "0.3.2"
|
||||
tempfile = "3.13.0"
|
||||
tempfile = "3.23.0"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = "2.0.16"
|
||||
time = "0.3"
|
||||
@@ -188,7 +193,7 @@ unwrap_used = "deny"
|
||||
# cargo-shear cannot see the platform-specific openssl-sys usage, so we
|
||||
# silence the false positive here instead of deleting a real dependency.
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = ["openssl-sys"]
|
||||
ignored = ["openssl-sys", "codex-utils-readiness"]
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
|
||||
@@ -69,13 +68,6 @@ const PRESETS: &[ModelPreset] = &[
|
||||
},
|
||||
];
|
||||
|
||||
pub fn builtin_model_presets(auth_mode: Option<AuthMode>) -> Vec<ModelPreset> {
|
||||
match auth_mode {
|
||||
Some(AuthMode::ApiKey) => PRESETS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| p.model != GPT_5_CODEX_MEDIUM_MODEL)
|
||||
.collect(),
|
||||
_ => PRESETS.to_vec(),
|
||||
}
|
||||
pub fn builtin_model_presets(_auth_mode: Option<AuthMode>) -> Vec<ModelPreset> {
|
||||
PRESETS.to_vec()
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ pub(crate) async fn stream_chat_completions(
|
||||
client: &reqwest::Client,
|
||||
provider: &ModelProviderInfo,
|
||||
) -> Result<ResponseStream> {
|
||||
if prompt.output_schema.is_some() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
"output_schema is not supported for Chat Completions API".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build messages array
|
||||
let mut messages = Vec::<serde_json::Value>::new();
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::openai_tools::create_tools_json_for_responses_api;
|
||||
use crate::protocol::RateLimitSnapshotEvent;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::token_data::PlanType;
|
||||
use crate::util::backoff;
|
||||
@@ -185,7 +185,7 @@ impl ModelClient {
|
||||
|
||||
// Only include `text.verbosity` for GPT-5 family models
|
||||
let text = if self.config.model_family.family == "gpt-5" {
|
||||
create_text_param_for_request(self.config.model_verbosity)
|
||||
create_text_param_for_request(self.config.model_verbosity, &prompt.output_schema)
|
||||
} else {
|
||||
if self.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
@@ -330,6 +330,7 @@ impl ModelClient {
|
||||
}
|
||||
|
||||
if status == StatusCode::TOO_MANY_REQUESTS {
|
||||
let rate_limit_snapshot = parse_rate_limit_snapshot(res.headers());
|
||||
let body = res.json::<ErrorResponse>().await.ok();
|
||||
if let Some(ErrorResponse { error }) = body {
|
||||
if error.r#type.as_deref() == Some("usage_limit_reached") {
|
||||
@@ -343,6 +344,7 @@ impl ModelClient {
|
||||
return Err(CodexErr::UsageLimitReached(UsageLimitReachedError {
|
||||
plan_type,
|
||||
resets_in_seconds,
|
||||
rate_limits: rate_limit_snapshot,
|
||||
}));
|
||||
} else if error.r#type.as_deref() == Some("usage_not_included") {
|
||||
return Err(CodexErr::UsageNotIncluded);
|
||||
@@ -485,7 +487,7 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEvent> {
|
||||
fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?;
|
||||
let secondary_used_percent = parse_header_f64(headers, "x-codex-secondary-used-percent")?;
|
||||
let primary_to_secondary_ratio_percent =
|
||||
@@ -493,7 +495,7 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
|
||||
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
|
||||
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
|
||||
|
||||
Some(RateLimitSnapshotEvent {
|
||||
Some(RateLimitSnapshot {
|
||||
primary_used_percent,
|
||||
secondary_used_percent,
|
||||
primary_to_secondary_ratio_percent,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::protocol::RateLimitSnapshotEvent;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TokenUsage;
|
||||
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -10,6 +10,7 @@ use codex_protocol::config_types::Verbosity as VerbosityConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
use std::pin::Pin;
|
||||
@@ -32,6 +33,9 @@ pub struct Prompt {
|
||||
|
||||
/// Optional override for the built-in BASE_INSTRUCTIONS.
|
||||
pub base_instructions_override: Option<String>,
|
||||
|
||||
/// Optional the output schema for the model's response.
|
||||
pub output_schema: Option<Value>,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
@@ -79,7 +83,7 @@ pub enum ResponseEvent {
|
||||
WebSearchCallBegin {
|
||||
call_id: String,
|
||||
},
|
||||
RateLimits(RateLimitSnapshotEvent),
|
||||
RateLimits(RateLimitSnapshot),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -90,14 +94,31 @@ pub(crate) struct Reasoning {
|
||||
pub(crate) summary: Option<ReasoningSummaryConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum TextFormatType {
|
||||
#[default]
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone)]
|
||||
pub(crate) struct TextFormat {
|
||||
pub(crate) r#type: TextFormatType,
|
||||
pub(crate) strict: bool,
|
||||
pub(crate) schema: Value,
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
/// Controls under the `text` field in the Responses API for GPT-5.
|
||||
#[derive(Debug, Serialize, Default, Clone, Copy)]
|
||||
#[derive(Debug, Serialize, Default, Clone)]
|
||||
pub(crate) struct TextControls {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) verbosity: Option<OpenAiVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) format: Option<TextFormat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone, Copy)]
|
||||
#[derive(Debug, Serialize, Default, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum OpenAiVerbosity {
|
||||
Low,
|
||||
@@ -156,9 +177,20 @@ pub(crate) fn create_reasoning_param_for_request(
|
||||
|
||||
pub(crate) fn create_text_param_for_request(
|
||||
verbosity: Option<VerbosityConfig>,
|
||||
output_schema: &Option<Value>,
|
||||
) -> Option<TextControls> {
|
||||
verbosity.map(|v| TextControls {
|
||||
verbosity: Some(v.into()),
|
||||
if verbosity.is_none() && output_schema.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TextControls {
|
||||
verbosity: verbosity.map(std::convert::Into::into),
|
||||
format: output_schema.as_ref().map(|schema| TextFormat {
|
||||
r#type: TextFormatType::JsonSchema,
|
||||
strict: true,
|
||||
schema: schema.clone(),
|
||||
name: "codex_output_schema".to_string(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,6 +287,7 @@ mod tests {
|
||||
prompt_cache_key: None,
|
||||
text: Some(TextControls {
|
||||
verbosity: Some(OpenAiVerbosity::Low),
|
||||
format: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -267,6 +300,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_schema_with_strict_format() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
let tools: Vec<serde_json::Value> = vec![];
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {"type": "string"}
|
||||
},
|
||||
"required": ["answer"],
|
||||
});
|
||||
let text_controls =
|
||||
create_text_param_for_request(None, &Some(schema.clone())).expect("text controls");
|
||||
|
||||
let req = ResponsesApiRequest {
|
||||
model: "gpt-5",
|
||||
instructions: "i",
|
||||
input: &input,
|
||||
tools: &tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
text: Some(text_controls),
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
let text = v.get("text").expect("text field");
|
||||
assert!(text.get("verbosity").is_none());
|
||||
let format = text.get("format").expect("format field");
|
||||
|
||||
assert_eq!(
|
||||
format.get("name"),
|
||||
Some(&serde_json::Value::String("codex_output_schema".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
format.get("type"),
|
||||
Some(&serde_json::Value::String("json_schema".into()))
|
||||
);
|
||||
assert_eq!(format.get("strict"), Some(&serde_json::Value::Bool(true)));
|
||||
assert_eq!(format.get("schema"), Some(&schema));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_text_when_not_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -11,6 +10,7 @@ use crate::AuthManager;
|
||||
use crate::client_common::REVIEW_PROMPT;
|
||||
use crate::event_mapping::map_response_item_to_event_messages;
|
||||
use crate::review_format::format_review_findings_block;
|
||||
use crate::user_notification::UserNotifier;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
@@ -30,6 +30,7 @@ use mcp_types::CallToolResult;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::AbortHandle;
|
||||
@@ -98,7 +99,7 @@ use crate::protocol::ListCustomPromptsResponseEvent;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::PatchApplyBeginEvent;
|
||||
use crate::protocol::PatchApplyEndEvent;
|
||||
use crate::protocol::RateLimitSnapshotEvent;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::ReviewOutputEvent;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
@@ -108,7 +109,6 @@ use crate::protocol::Submission;
|
||||
use crate::protocol::TaskCompleteEvent;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WebSearchBeginEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
@@ -116,6 +116,7 @@ use crate::rollout::RolloutRecorderParams;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_command_safety;
|
||||
use crate::safety::assess_safety_for_untrusted_command;
|
||||
use crate::services::SessionServices;
|
||||
use crate::shell;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
@@ -186,7 +187,7 @@ impl Codex {
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
notify: config.notify.clone(),
|
||||
notify: UserNotifier::new(config.notify.clone()),
|
||||
cwd: config.cwd.clone(),
|
||||
};
|
||||
|
||||
@@ -250,17 +251,7 @@ impl Codex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable state of the agent
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
approved_commands: HashSet<Vec<String>>,
|
||||
current_task: Option<AgentTask>,
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
history: ConversationHistory,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
latest_rate_limits: Option<RateLimitSnapshotEvent>,
|
||||
}
|
||||
use crate::state::SessionState;
|
||||
|
||||
/// Context for an initialized model agent
|
||||
///
|
||||
@@ -268,23 +259,8 @@ struct State {
|
||||
pub(crate) struct Session {
|
||||
conversation_id: ConversationId,
|
||||
tx_event: Sender<Event>,
|
||||
|
||||
/// Manager for external MCP servers/tools.
|
||||
mcp_connection_manager: McpConnectionManager,
|
||||
session_manager: ExecSessionManager,
|
||||
unified_exec_manager: UnifiedExecSessionManager,
|
||||
|
||||
/// External notifier command (will be passed as args to exec()). When
|
||||
/// `None` this feature is disabled.
|
||||
notify: Option<Vec<String>>,
|
||||
|
||||
/// Optional rollout recorder for persisting the conversation transcript so
|
||||
/// sessions can be replayed or inspected later.
|
||||
rollout: Mutex<Option<RolloutRecorder>>,
|
||||
state: Mutex<State>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
user_shell: shell::Shell,
|
||||
show_raw_agent_reasoning: bool,
|
||||
state: Mutex<SessionState>,
|
||||
services: SessionServices,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -303,6 +279,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
|
||||
pub(crate) tools_config: ToolsConfig,
|
||||
pub(crate) is_review_mode: bool,
|
||||
pub(crate) final_output_json_schema: Option<Value>,
|
||||
}
|
||||
|
||||
impl TurnContext {
|
||||
@@ -335,10 +312,7 @@ struct ConfigureSession {
|
||||
/// How to sandbox commands executed in the system
|
||||
sandbox_policy: SandboxPolicy,
|
||||
|
||||
/// Optional external notifier command tokens. Present only when the
|
||||
/// client wants the agent to spawn a program after each completed
|
||||
/// turn.
|
||||
notify: Option<Vec<String>>,
|
||||
notify: UserNotifier,
|
||||
|
||||
/// Working directory that should be treated as the *root* of the
|
||||
/// session. All relative paths supplied by the model as well as the
|
||||
@@ -414,10 +388,7 @@ impl Session {
|
||||
})?;
|
||||
let rollout_path = rollout_recorder.rollout_path.clone();
|
||||
// Create the mutable state for the Session.
|
||||
let state = State {
|
||||
history: ConversationHistory::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let state = SessionState::new();
|
||||
|
||||
// Handle MCP manager result and record any startup failures.
|
||||
let (mcp_connection_manager, failed_clients) = match mcp_res {
|
||||
@@ -473,19 +444,24 @@ impl Session {
|
||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
is_review_mode: false,
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager,
|
||||
session_manager: ExecSessionManager::default(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notify,
|
||||
state: Mutex::new(state),
|
||||
notifier: notify,
|
||||
rollout: Mutex::new(Some(rollout_recorder)),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
user_shell: default_shell,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
};
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
state: Mutex::new(state),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
@@ -586,13 +562,13 @@ impl Session {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
reason: Option<String>,
|
||||
) -> oneshot::Receiver<ReviewDecision> {
|
||||
) -> ReviewDecision {
|
||||
// Add the tx_approve callback to the map before sending the request.
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
let event_id = sub_id.clone();
|
||||
let prev_entry = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.pending_approvals.insert(sub_id, tx_approve)
|
||||
state.insert_pending_approval(sub_id, tx_approve)
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for sub_id: {event_id}");
|
||||
@@ -608,7 +584,7 @@ impl Session {
|
||||
}),
|
||||
};
|
||||
self.send_event(event).await;
|
||||
rx_approve
|
||||
rx_approve.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn request_patch_approval(
|
||||
@@ -624,7 +600,7 @@ impl Session {
|
||||
let event_id = sub_id.clone();
|
||||
let prev_entry = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.pending_approvals.insert(sub_id, tx_approve)
|
||||
state.insert_pending_approval(sub_id, tx_approve)
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending approval for sub_id: {event_id}");
|
||||
@@ -646,7 +622,7 @@ impl Session {
|
||||
pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
|
||||
let entry = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.pending_approvals.remove(sub_id)
|
||||
state.remove_pending_approval(sub_id)
|
||||
};
|
||||
match entry {
|
||||
Some(tx_approve) => {
|
||||
@@ -660,7 +636,7 @@ impl Session {
|
||||
|
||||
pub async fn add_approved_command(&self, cmd: Vec<String>) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.approved_commands.insert(cmd);
|
||||
state.add_approved_command(cmd);
|
||||
}
|
||||
|
||||
/// Records input items: always append to conversation history and
|
||||
@@ -700,7 +676,7 @@ impl Session {
|
||||
/// Append ResponseItems to the in-memory conversation history only.
|
||||
async fn record_into_history(&self, items: &[ResponseItem]) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.history.record_items(items.iter());
|
||||
state.record_items(items.iter());
|
||||
}
|
||||
|
||||
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
|
||||
@@ -721,14 +697,14 @@ impl Session {
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.approval_policy),
|
||||
Some(turn_context.sandbox_policy.clone()),
|
||||
Some(self.user_shell.clone()),
|
||||
Some(self.services.user_shell.clone()),
|
||||
)));
|
||||
items
|
||||
}
|
||||
|
||||
async fn persist_rollout_items(&self, items: &[RolloutItem]) {
|
||||
let recorder = {
|
||||
let guard = self.rollout.lock().await;
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(rec) = recorder
|
||||
@@ -740,31 +716,40 @@ impl Session {
|
||||
|
||||
async fn update_token_usage_info(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
turn_context: &TurnContext,
|
||||
token_usage: Option<&TokenUsage>,
|
||||
) {
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(token_usage) = token_usage {
|
||||
let info = TokenUsageInfo::new_or_append(
|
||||
&state.token_info,
|
||||
&Some(token_usage.clone()),
|
||||
turn_context.client.get_model_context_window(),
|
||||
);
|
||||
state.token_info = info;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
if let Some(token_usage) = token_usage {
|
||||
state.update_token_info_from_usage(
|
||||
token_usage,
|
||||
turn_context.client.get_model_context_window(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.send_token_count_event(sub_id).await;
|
||||
}
|
||||
|
||||
async fn update_rate_limits(&self, new_rate_limits: RateLimitSnapshotEvent) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.latest_rate_limits = Some(new_rate_limits);
|
||||
async fn update_rate_limits(&self, sub_id: &str, new_rate_limits: RateLimitSnapshot) {
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_rate_limits(new_rate_limits);
|
||||
}
|
||||
self.send_token_count_event(sub_id).await;
|
||||
}
|
||||
|
||||
async fn get_token_count_event(&self) -> TokenCountEvent {
|
||||
let state = self.state.lock().await;
|
||||
TokenCountEvent {
|
||||
info: state.token_info.clone(),
|
||||
rate_limits: state.latest_rate_limits.clone(),
|
||||
}
|
||||
async fn send_token_count_event(&self, sub_id: &str) {
|
||||
let (info, rate_limits) = {
|
||||
let state = self.state.lock().await;
|
||||
state.token_info_and_rate_limits()
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::TokenCount(TokenCountEvent { info, rate_limits }),
|
||||
};
|
||||
self.send_event(event).await;
|
||||
}
|
||||
|
||||
/// Record a user input item to conversation history and also persist a
|
||||
@@ -776,8 +761,10 @@ impl Session {
|
||||
.await;
|
||||
|
||||
// Derive user message events and persist only UserMessage to rollout
|
||||
let msgs =
|
||||
map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning);
|
||||
let msgs = map_response_item_to_event_messages(
|
||||
&response_item,
|
||||
self.services.show_raw_agent_reasoning,
|
||||
);
|
||||
let user_msgs: Vec<RolloutItem> = msgs
|
||||
.into_iter()
|
||||
.filter_map(|m| match m {
|
||||
@@ -976,7 +963,7 @@ impl Session {
|
||||
pub async fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||||
let history = {
|
||||
let state = self.state.lock().await;
|
||||
state.history.contents()
|
||||
state.history_snapshot()
|
||||
};
|
||||
[history, extra].concat()
|
||||
}
|
||||
@@ -985,7 +972,7 @@ impl Session {
|
||||
pub async fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
|
||||
let mut state = self.state.lock().await;
|
||||
if state.current_task.is_some() {
|
||||
state.pending_input.push(input.into());
|
||||
state.push_pending_input(input.into());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(input)
|
||||
@@ -994,13 +981,7 @@ impl Session {
|
||||
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
let mut state = self.state.lock().await;
|
||||
if state.pending_input.is_empty() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut ret = Vec::new();
|
||||
std::mem::swap(&mut ret, &mut state.pending_input);
|
||||
ret
|
||||
}
|
||||
state.take_pending_input()
|
||||
}
|
||||
|
||||
pub async fn call_tool(
|
||||
@@ -1009,7 +990,8 @@ impl Session {
|
||||
tool: &str,
|
||||
arguments: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.mcp_connection_manager
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.call_tool(server, tool, arguments)
|
||||
.await
|
||||
}
|
||||
@@ -1017,8 +999,7 @@ impl Session {
|
||||
pub async fn interrupt_task(&self) {
|
||||
info!("interrupt received: abort current task, if any");
|
||||
let mut state = self.state.lock().await;
|
||||
state.pending_approvals.clear();
|
||||
state.pending_input.clear();
|
||||
state.clear_pending();
|
||||
if let Some(task) = state.current_task.take() {
|
||||
task.abort(TurnAbortReason::Interrupted);
|
||||
}
|
||||
@@ -1026,41 +1007,15 @@ impl Session {
|
||||
|
||||
fn interrupt_task_sync(&self) {
|
||||
if let Ok(mut state) = self.state.try_lock() {
|
||||
state.pending_approvals.clear();
|
||||
state.pending_input.clear();
|
||||
state.clear_pending();
|
||||
if let Some(task) = state.current_task.take() {
|
||||
task.abort(TurnAbortReason::Interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the configured notifier (if any) with the given JSON payload as
|
||||
/// the last argument. Failures are logged but otherwise ignored so that
|
||||
/// notification issues do not interfere with the main workflow.
|
||||
fn maybe_notify(&self, notification: UserNotification) {
|
||||
let Some(notify_command) = &self.notify else {
|
||||
return;
|
||||
};
|
||||
|
||||
if notify_command.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(json) = serde_json::to_string(¬ification) else {
|
||||
error!("failed to serialise notification payload");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut command = std::process::Command::new(¬ify_command[0]);
|
||||
if notify_command.len() > 1 {
|
||||
command.args(¬ify_command[1..]);
|
||||
}
|
||||
command.arg(json);
|
||||
|
||||
// Fire-and-forget – we do not wait for completion.
|
||||
if let Err(e) = command.spawn() {
|
||||
warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
|
||||
}
|
||||
pub(crate) fn notifier(&self) -> &UserNotifier {
|
||||
&self.services.notifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,16 +1101,13 @@ impl AgentTask {
|
||||
turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
compact_instructions: String,
|
||||
) -> Self {
|
||||
let handle = {
|
||||
let sess = sess.clone();
|
||||
let sub_id = sub_id.clone();
|
||||
let tc = Arc::clone(&turn_context);
|
||||
tokio::spawn(async move {
|
||||
compact::run_compact_task(sess, tc, sub_id, input, compact_instructions).await
|
||||
})
|
||||
.abort_handle()
|
||||
tokio::spawn(async move { compact::run_compact_task(sess, tc, sub_id, input).await })
|
||||
.abort_handle()
|
||||
};
|
||||
Self {
|
||||
sess,
|
||||
@@ -1269,6 +1221,7 @@ async fn submission_loop(
|
||||
shell_environment_policy: prev.shell_environment_policy.clone(),
|
||||
cwd: new_cwd.clone(),
|
||||
is_review_mode: false,
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
|
||||
// Install the new persistent context for subsequent tasks/turns.
|
||||
@@ -1303,6 +1256,7 @@ async fn submission_loop(
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
final_output_json_schema,
|
||||
} => {
|
||||
// attempt to inject input into current task
|
||||
if let Err(items) = sess.inject_input(items).await {
|
||||
@@ -1353,6 +1307,7 @@ async fn submission_loop(
|
||||
shell_environment_policy: turn_context.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
is_review_mode: false,
|
||||
final_output_json_schema,
|
||||
};
|
||||
|
||||
// if the environment context has changed, record it in the conversation history
|
||||
@@ -1432,7 +1387,7 @@ async fn submission_loop(
|
||||
let sub_id = sub.id.clone();
|
||||
|
||||
// This is a cheap lookup from the connection manager's cache.
|
||||
let tools = sess.mcp_connection_manager.list_all_tools();
|
||||
let tools = sess.services.mcp_connection_manager.list_all_tools();
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::McpListToolsResponse(
|
||||
@@ -1463,7 +1418,7 @@ async fn submission_loop(
|
||||
// Attempt to inject input into current task
|
||||
if let Err(items) = sess
|
||||
.inject_input(vec![InputItem::Text {
|
||||
text: compact::COMPACT_TRIGGER_TEXT.to_string(),
|
||||
text: compact::SUMMARIZATION_PROMPT.to_string(),
|
||||
}])
|
||||
.await
|
||||
{
|
||||
@@ -1482,7 +1437,7 @@ async fn submission_loop(
|
||||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||||
// that inspect the rollout file do not race with the background writer.
|
||||
let recorder_opt = {
|
||||
let mut guard = sess.rollout.lock().await;
|
||||
let mut guard = sess.services.rollout.lock().await;
|
||||
guard.take()
|
||||
};
|
||||
if let Some(rec) = recorder_opt
|
||||
@@ -1509,7 +1464,7 @@ async fn submission_loop(
|
||||
let sub_id = sub.id.clone();
|
||||
// Flush rollout writes before returning the path so readers observe a consistent file.
|
||||
let (path, rec_opt) = {
|
||||
let guard = sess.rollout.lock().await;
|
||||
let guard = sess.services.rollout.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(rec) => (rec.get_rollout_path(), Some(rec.clone())),
|
||||
None => {
|
||||
@@ -1607,6 +1562,7 @@ async fn spawn_review_thread(
|
||||
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
|
||||
cwd: parent_turn_context.cwd.clone(),
|
||||
is_review_mode: true,
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
@@ -1883,11 +1839,12 @@ async fn run_task(
|
||||
last_agent_message = get_last_assistant_message_from_turn(
|
||||
&items_to_record_in_conversation_history,
|
||||
);
|
||||
sess.maybe_notify(UserNotification::AgentTurnComplete {
|
||||
turn_id: sub_id.clone(),
|
||||
input_messages: turn_input_messages,
|
||||
last_assistant_message: last_agent_message.clone(),
|
||||
});
|
||||
sess.notifier()
|
||||
.notify(&UserNotification::AgentTurnComplete {
|
||||
turn_id: sub_id.clone(),
|
||||
input_messages: turn_input_messages,
|
||||
last_assistant_message: last_agent_message.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
@@ -1965,13 +1922,14 @@ async fn run_turn(
|
||||
) -> CodexResult<TurnRunResult> {
|
||||
let tools = get_openai_tools(
|
||||
&turn_context.tools_config,
|
||||
Some(sess.mcp_connection_manager.list_all_tools()),
|
||||
Some(sess.services.mcp_connection_manager.list_all_tools()),
|
||||
);
|
||||
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools,
|
||||
base_instructions_override: turn_context.base_instructions.clone(),
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
};
|
||||
|
||||
let mut retries = 0;
|
||||
@@ -1980,9 +1938,14 @@ async fn run_turn(
|
||||
Ok(output) => return Ok(output),
|
||||
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
|
||||
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
|
||||
Err(e @ (CodexErr::UsageLimitReached(_) | CodexErr::UsageNotIncluded)) => {
|
||||
return Err(e);
|
||||
Err(CodexErr::UsageLimitReached(e)) => {
|
||||
let rate_limits = e.rate_limits.clone();
|
||||
if let Some(rate_limits) = rate_limits {
|
||||
sess.update_rate_limits(&sub_id, rate_limits).await;
|
||||
}
|
||||
return Err(CodexErr::UsageLimitReached(e));
|
||||
}
|
||||
Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded),
|
||||
Err(e) => {
|
||||
// Use the configured provider-specific stream retry budget.
|
||||
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
||||
@@ -2155,20 +2118,13 @@ async fn try_run_turn(
|
||||
ResponseEvent::RateLimits(snapshot) => {
|
||||
// Update internal state with latest rate limits, but defer sending until
|
||||
// token usage is available to avoid duplicate TokenCount events.
|
||||
sess.update_rate_limits(snapshot).await;
|
||||
sess.update_rate_limits(sub_id, snapshot).await;
|
||||
}
|
||||
ResponseEvent::Completed {
|
||||
response_id: _,
|
||||
token_usage,
|
||||
} => {
|
||||
sess.update_token_usage_info(turn_context, token_usage.as_ref())
|
||||
.await;
|
||||
let token_event = sess.get_token_count_event().await;
|
||||
let _ = sess
|
||||
.send_event(Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::TokenCount(token_event),
|
||||
})
|
||||
sess.update_token_usage_info(sub_id, turn_context, token_usage.as_ref())
|
||||
.await;
|
||||
|
||||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||||
@@ -2216,7 +2172,7 @@ async fn try_run_turn(
|
||||
sess.send_event(event).await;
|
||||
}
|
||||
ResponseEvent::ReasoningContentDelta(delta) => {
|
||||
if sess.show_raw_agent_reasoning {
|
||||
if sess.services.show_raw_agent_reasoning {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::AgentReasoningRawContentDelta(
|
||||
@@ -2338,7 +2294,10 @@ async fn handle_response_item(
|
||||
trace!("suppressing assistant Message in review mode");
|
||||
Vec::new()
|
||||
}
|
||||
_ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning),
|
||||
_ => map_response_item_to_event_messages(
|
||||
&item,
|
||||
sess.services.show_raw_agent_reasoning,
|
||||
),
|
||||
};
|
||||
for msg in msgs {
|
||||
let event = Event {
|
||||
@@ -2384,7 +2343,11 @@ async fn handle_unified_exec_tool_call(
|
||||
timeout_ms,
|
||||
};
|
||||
|
||||
let result = sess.unified_exec_manager.handle_request(request).await;
|
||||
let result = sess
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.handle_request(request)
|
||||
.await;
|
||||
|
||||
let output_payload = match result {
|
||||
Ok(value) => {
|
||||
@@ -2559,6 +2522,7 @@ async fn handle_function_call(
|
||||
}
|
||||
};
|
||||
let result = sess
|
||||
.services
|
||||
.session_manager
|
||||
.handle_exec_command_request(exec_params)
|
||||
.await;
|
||||
@@ -2582,6 +2546,7 @@ async fn handle_function_call(
|
||||
}
|
||||
};
|
||||
let result = sess
|
||||
.services
|
||||
.session_manager
|
||||
.handle_write_stdin_request(write_stdin_params)
|
||||
.await;
|
||||
@@ -2593,7 +2558,7 @@ async fn handle_function_call(
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match sess.mcp_connection_manager.parse_tool_name(&name) {
|
||||
match sess.services.mcp_connection_manager.parse_tool_name(&name) {
|
||||
Some((server, tool_name)) => {
|
||||
handle_mcp_tool_call(sess, &sub_id, call_id, server, tool_name, arguments).await
|
||||
}
|
||||
@@ -2711,11 +2676,12 @@ fn maybe_translate_shell_command(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> ExecParams {
|
||||
let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_))
|
||||
let should_translate = matches!(sess.services.user_shell, crate::shell::Shell::PowerShell(_))
|
||||
|| turn_context.shell_environment_policy.use_profile;
|
||||
|
||||
if should_translate
|
||||
&& let Some(command) = sess
|
||||
.services
|
||||
.user_shell
|
||||
.format_default_shell_invocation(params.command.clone())
|
||||
{
|
||||
@@ -2830,7 +2796,7 @@ async fn handle_container_exec_with_params(
|
||||
¶ms.command,
|
||||
turn_context.approval_policy,
|
||||
&turn_context.sandbox_policy,
|
||||
&state.approved_commands,
|
||||
state.approved_commands_ref(),
|
||||
params.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
@@ -2842,7 +2808,7 @@ async fn handle_container_exec_with_params(
|
||||
let sandbox_type = match safety {
|
||||
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
|
||||
SafetyCheck::AskUser => {
|
||||
let rx_approve = sess
|
||||
let decision = sess
|
||||
.request_command_approval(
|
||||
sub_id.clone(),
|
||||
call_id.clone(),
|
||||
@@ -2851,7 +2817,7 @@ async fn handle_container_exec_with_params(
|
||||
params.justification.clone(),
|
||||
)
|
||||
.await;
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
match decision {
|
||||
ReviewDecision::Approved => (),
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
sess.add_approved_command(params.command.clone()).await;
|
||||
@@ -2909,7 +2875,7 @@ async fn handle_container_exec_with_params(
|
||||
sandbox_type,
|
||||
sandbox_policy: &turn_context.sandbox_policy,
|
||||
sandbox_cwd: &turn_context.cwd,
|
||||
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
|
||||
stdout_stream: if exec_command_context.apply_patch.is_some() {
|
||||
None
|
||||
} else {
|
||||
@@ -3012,7 +2978,7 @@ async fn handle_sandbox_error(
|
||||
sess.notify_background_event(&sub_id, format!("Execution failed: {error}"))
|
||||
.await;
|
||||
|
||||
let rx_approve = sess
|
||||
let decision = sess
|
||||
.request_command_approval(
|
||||
sub_id.clone(),
|
||||
call_id.clone(),
|
||||
@@ -3022,7 +2988,7 @@ async fn handle_sandbox_error(
|
||||
)
|
||||
.await;
|
||||
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
match decision {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
|
||||
// Persist this command as pre‑approved for the
|
||||
// remainder of the session so future
|
||||
@@ -3044,7 +3010,7 @@ async fn handle_sandbox_error(
|
||||
sandbox_type: SandboxType::None,
|
||||
sandbox_policy: &turn_context.sandbox_policy,
|
||||
sandbox_cwd: &turn_context.cwd,
|
||||
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe,
|
||||
stdout_stream: if exec_command_context.apply_patch.is_some() {
|
||||
None
|
||||
} else {
|
||||
@@ -3398,7 +3364,7 @@ mod tests {
|
||||
}),
|
||||
));
|
||||
|
||||
let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() });
|
||||
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
@@ -3411,7 +3377,7 @@ mod tests {
|
||||
session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)),
|
||||
);
|
||||
|
||||
let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() });
|
||||
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
@@ -3635,22 +3601,23 @@ mod tests {
|
||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||
tools_config,
|
||||
is_review_mode: false,
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: McpConnectionManager::default(),
|
||||
session_manager: ExecSessionManager::default(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notifier: UserNotifier::default(),
|
||||
rollout: Mutex::new(None),
|
||||
codex_linux_sandbox_exe: None,
|
||||
user_shell: shell::Shell::Unknown,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
};
|
||||
let session = Session {
|
||||
conversation_id,
|
||||
tx_event,
|
||||
mcp_connection_manager: McpConnectionManager::default(),
|
||||
session_manager: ExecSessionManager::default(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notify: None,
|
||||
rollout: Mutex::new(None),
|
||||
state: Mutex::new(State {
|
||||
history: ConversationHistory::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
user_shell: shell::Shell::Unknown,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
state: Mutex::new(SessionState::new()),
|
||||
services,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
};
|
||||
(session, turn_context)
|
||||
|
||||
@@ -27,8 +27,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use futures::prelude::*;
|
||||
|
||||
pub(super) const COMPACT_TRIGGER_TEXT: &str = "Start Summarization";
|
||||
const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
|
||||
pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
|
||||
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -44,13 +43,7 @@ pub(super) async fn spawn_compact_task(
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
) {
|
||||
let task = AgentTask::compact(
|
||||
sess.clone(),
|
||||
turn_context,
|
||||
sub_id,
|
||||
input,
|
||||
SUMMARIZATION_PROMPT.to_string(),
|
||||
);
|
||||
let task = AgentTask::compact(sess.clone(), turn_context, sub_id, input);
|
||||
sess.set_task(task).await;
|
||||
}
|
||||
|
||||
@@ -60,17 +53,9 @@ pub(super) async fn run_inline_auto_compact_task(
|
||||
) {
|
||||
let sub_id = sess.next_internal_sub_id();
|
||||
let input = vec![InputItem::Text {
|
||||
text: COMPACT_TRIGGER_TEXT.to_string(),
|
||||
text: SUMMARIZATION_PROMPT.to_string(),
|
||||
}];
|
||||
run_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
sub_id,
|
||||
input,
|
||||
SUMMARIZATION_PROMPT.to_string(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
run_compact_task_inner(sess, turn_context, sub_id, input, false).await;
|
||||
}
|
||||
|
||||
pub(super) async fn run_compact_task(
|
||||
@@ -78,7 +63,6 @@ pub(super) async fn run_compact_task(
|
||||
turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
compact_instructions: String,
|
||||
) {
|
||||
let start_event = Event {
|
||||
id: sub_id.clone(),
|
||||
@@ -87,15 +71,7 @@ pub(super) async fn run_compact_task(
|
||||
}),
|
||||
};
|
||||
sess.send_event(start_event).await;
|
||||
run_compact_task_inner(
|
||||
sess.clone(),
|
||||
turn_context,
|
||||
sub_id.clone(),
|
||||
input,
|
||||
compact_instructions,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input, true).await;
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
@@ -110,19 +86,16 @@ async fn run_compact_task_inner(
|
||||
turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
compact_instructions: String,
|
||||
remove_task_on_completion: bool,
|
||||
) {
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
let instructions_override = compact_instructions;
|
||||
let turn_input = sess
|
||||
.turn_input_with_history(vec![initial_input_for_turn.clone().into()])
|
||||
.await;
|
||||
|
||||
let prompt = Prompt {
|
||||
input: turn_input,
|
||||
tools: Vec::new(),
|
||||
base_instructions_override: Some(instructions_override),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
||||
@@ -180,7 +153,7 @@ async fn run_compact_task_inner(
|
||||
}
|
||||
let history_snapshot = {
|
||||
let state = sess.state.lock().await;
|
||||
state.history.contents()
|
||||
state.history_snapshot()
|
||||
};
|
||||
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
|
||||
let user_messages = collect_user_messages(&history_snapshot);
|
||||
@@ -188,7 +161,7 @@ async fn run_compact_task_inner(
|
||||
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
|
||||
{
|
||||
let mut state = sess.state.lock().await;
|
||||
state.history.replace(new_history);
|
||||
state.replace_history(new_history);
|
||||
}
|
||||
|
||||
let rollout_item = RolloutItem::Compacted(CompactedItem {
|
||||
@@ -298,7 +271,7 @@ async fn drain_to_completed(
|
||||
match event {
|
||||
Ok(ResponseEvent::OutputItemDone(item)) => {
|
||||
let mut state = sess.state.lock().await;
|
||||
state.history.record_items(std::slice::from_ref(&item));
|
||||
state.record_items(std::slice::from_ref(&item));
|
||||
}
|
||||
Ok(ResponseEvent::Completed { .. }) => {
|
||||
return Ok(());
|
||||
|
||||
@@ -37,7 +37,7 @@ use toml_edit::DocumentMut;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
|
||||
const OPENAI_DEFAULT_MODEL: &str = "gpt-5";
|
||||
const OPENAI_DEFAULT_MODEL: &str = "gpt-5-codex";
|
||||
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5-codex";
|
||||
pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5-codex";
|
||||
|
||||
@@ -54,7 +54,7 @@ pub struct Config {
|
||||
/// Optional override of model selection.
|
||||
pub model: String,
|
||||
|
||||
/// Model used specifically for review sessions. Defaults to "gpt-5".
|
||||
/// Model used specifically for review sessions. Defaults to "gpt-5-codex".
|
||||
pub review_model: String,
|
||||
|
||||
pub model_family: ModelFamily,
|
||||
@@ -1366,7 +1366,7 @@ startup_timeout_ms = 2500
|
||||
tokio::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "medium"
|
||||
|
||||
[profiles.dev]
|
||||
@@ -1441,7 +1441,7 @@ model = "gpt-4"
|
||||
model_reasoning_effort = "medium"
|
||||
|
||||
[profiles.prod]
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
@@ -1472,7 +1472,7 @@ model = "gpt-5"
|
||||
.profiles
|
||||
.get("prod")
|
||||
.and_then(|profile| profile.model.as_deref()),
|
||||
Some("gpt-5"),
|
||||
Some("gpt-5-codex"),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -228,7 +228,7 @@ mod tests {
|
||||
codex_home,
|
||||
None,
|
||||
&[
|
||||
(&[CONFIG_KEY_MODEL], "gpt-5"),
|
||||
(&[CONFIG_KEY_MODEL], "gpt-5-codex"),
|
||||
(&[CONFIG_KEY_EFFORT], "high"),
|
||||
],
|
||||
)
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
.expect("persist");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let expected = r#"model = "gpt-5"
|
||||
let expected = r#"model = "gpt-5-codex"
|
||||
model_reasoning_effort = "high"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
@@ -348,7 +348,7 @@ model_reasoning_effort = "high"
|
||||
&[
|
||||
(&["a", "b", "c"], "v"),
|
||||
(&["x"], "y"),
|
||||
(&["profiles", "p1", CONFIG_KEY_MODEL], "gpt-5"),
|
||||
(&["profiles", "p1", CONFIG_KEY_MODEL], "gpt-5-codex"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
@@ -361,7 +361,7 @@ model_reasoning_effort = "high"
|
||||
c = "v"
|
||||
|
||||
[profiles.p1]
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
@@ -454,7 +454,7 @@ existing = "keep"
|
||||
codex_home,
|
||||
None,
|
||||
&[
|
||||
(&[CONFIG_KEY_MODEL], "gpt-5"),
|
||||
(&[CONFIG_KEY_MODEL], "gpt-5-codex"),
|
||||
(&[CONFIG_KEY_EFFORT], "minimal"),
|
||||
],
|
||||
)
|
||||
@@ -466,7 +466,7 @@ existing = "keep"
|
||||
# should be preserved
|
||||
|
||||
existing = "keep"
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "minimal"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
@@ -524,7 +524,7 @@ model = "o3"
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
// Seed with a model value only
|
||||
let seed = "model = \"gpt-5\"\n";
|
||||
let seed = "model = \"gpt-5-codex\"\n";
|
||||
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed)
|
||||
.await
|
||||
.expect("seed write");
|
||||
@@ -535,7 +535,7 @@ model = "o3"
|
||||
.expect("persist");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let expected = r#"model = "gpt-5"
|
||||
let expected = r#"model = "gpt-5-codex"
|
||||
model_reasoning_effort = "high"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
@@ -579,7 +579,7 @@ model = "o4-mini"
|
||||
|
||||
// No active profile key; we'll target an explicit override
|
||||
let seed = r#"[profiles.team]
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
"#;
|
||||
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed)
|
||||
.await
|
||||
@@ -595,7 +595,7 @@ model = "gpt-5"
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let expected = r#"[profiles.team]
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "minimal"
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
@@ -611,7 +611,7 @@ model_reasoning_effort = "minimal"
|
||||
codex_home,
|
||||
None,
|
||||
&[
|
||||
(&[CONFIG_KEY_MODEL], Some("gpt-5")),
|
||||
(&[CONFIG_KEY_MODEL], Some("gpt-5-codex")),
|
||||
(&[CONFIG_KEY_EFFORT], None),
|
||||
],
|
||||
)
|
||||
@@ -619,7 +619,7 @@ model_reasoning_effort = "minimal"
|
||||
.expect("persist");
|
||||
|
||||
let contents = read_config(codex_home).await;
|
||||
let expected = "model = \"gpt-5\"\n";
|
||||
let expected = "model = \"gpt-5-codex\"\n";
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
@@ -670,7 +670,7 @@ model = "o3"
|
||||
let tmpdir = tempdir().expect("tmp");
|
||||
let codex_home = tmpdir.path();
|
||||
|
||||
let seed = r#"model = "gpt-5"
|
||||
let seed = r#"model = "gpt-5-codex"
|
||||
model_reasoning_effort = "medium"
|
||||
"#;
|
||||
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), seed)
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::exec::ExecToolCallOutput;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json;
|
||||
use std::io;
|
||||
@@ -104,6 +105,9 @@ pub enum CodexErr {
|
||||
#[error("codex-linux-sandbox was required but not provided")]
|
||||
LandlockSandboxExecutableNotProvided,
|
||||
|
||||
#[error("unsupported operation: {0}")]
|
||||
UnsupportedOperation(String),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Automatic conversions for common external error types
|
||||
// -----------------------------------------------------------------
|
||||
@@ -135,6 +139,7 @@ pub enum CodexErr {
|
||||
pub struct UsageLimitReachedError {
|
||||
pub(crate) plan_type: Option<PlanType>,
|
||||
pub(crate) resets_in_seconds: Option<u64>,
|
||||
pub(crate) rate_limits: Option<RateLimitSnapshot>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
@@ -263,11 +268,22 @@ pub fn get_error_message_ui(e: &CodexErr) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
primary_used_percent: 0.5,
|
||||
secondary_used_percent: 0.3,
|
||||
primary_to_secondary_ratio_percent: 0.7,
|
||||
primary_window_minutes: 60,
|
||||
secondary_window_minutes: 120,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_plus_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_in_seconds: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -280,6 +296,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Free)),
|
||||
resets_in_seconds: Some(3600),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -292,6 +309,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_in_seconds: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -304,6 +322,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Team)),
|
||||
resets_in_seconds: Some(3600),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -316,6 +335,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Business)),
|
||||
resets_in_seconds: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -328,6 +348,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
resets_in_seconds: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -340,6 +361,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_in_seconds: Some(5 * 60),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -352,6 +374,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_in_seconds: Some(3 * 3600 + 32 * 60),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -364,6 +387,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -376,6 +400,7 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_in_seconds: Some(30),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
||||
@@ -7,14 +7,27 @@ use std::path::PathBuf;
|
||||
|
||||
pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json";
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InternalStorage {
|
||||
#[serde(skip)]
|
||||
storage_path: PathBuf,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_gpt_5_codex_model_prompt_seen")]
|
||||
pub gpt_5_codex_model_prompt_seen: bool,
|
||||
}
|
||||
|
||||
const fn default_gpt_5_codex_model_prompt_seen() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for InternalStorage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: PathBuf::new(),
|
||||
gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(jif) generalise all the file writers and build proper async channel inserters.
|
||||
impl InternalStorage {
|
||||
pub fn load(codex_home: &Path) -> Self {
|
||||
|
||||
@@ -47,6 +47,7 @@ pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod conversation_manager;
|
||||
mod event_mapping;
|
||||
pub mod review_format;
|
||||
mod services;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
@@ -75,6 +76,7 @@ pub use rollout::find_conversation_path_by_id_str;
|
||||
pub use rollout::list::ConversationItem;
|
||||
pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
mod state;
|
||||
mod user_notification;
|
||||
pub mod util;
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
|
||||
// https://platform.openai.com/docs/models/gpt-3.5-turbo
|
||||
"gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)),
|
||||
|
||||
_ if slug.starts_with("gpt-5-codex") => Some(ModelInfo {
|
||||
context_window: 272_000,
|
||||
max_output_tokens: 128_000,
|
||||
auto_compact_token_limit: Some(250_000),
|
||||
}),
|
||||
|
||||
_ if slug.starts_with("gpt-5") => Some(ModelInfo::new(272_000, 128_000)),
|
||||
|
||||
_ if slug.starts_with("codex-") => Some(ModelInfo::new(272_000, 128_000)),
|
||||
|
||||
23
codex-rs/core/src/services/mod.rs
Normal file
23
codex-rs/core/src/services/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Group long‑lived helpers/managers for a session.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::exec_command::ExecSessionManager;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_notification::UserNotifier;
|
||||
|
||||
/// Container for side‑effectful services and helpers used by `Session`.
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: McpConnectionManager,
|
||||
pub(crate) session_manager: ExecSessionManager,
|
||||
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
|
||||
pub(crate) notifier: UserNotifier,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) user_shell: crate::shell::Shell,
|
||||
pub(crate) show_raw_agent_reasoning: bool,
|
||||
}
|
||||
10
codex-rs/core/src/state/mod.rs
Normal file
10
codex-rs/core/src/state/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Session/turn state module scaffolding.
|
||||
//!
|
||||
//! This module will encapsulate all mutable state for a Codex session.
|
||||
//! It starts with lightweight placeholders to enable incremental refactors
|
||||
//! without changing behaviour.
|
||||
|
||||
mod session;
|
||||
mod turn;
|
||||
|
||||
pub(crate) use session::SessionState;
|
||||
121
codex-rs/core/src/state/session.rs
Normal file
121
codex-rs/core/src/state/session.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Session-wide mutable state.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::codex::AgentTask;
|
||||
use crate::conversation_history::ConversationHistory;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
|
||||
/// Persistent, session-scoped state previously stored directly on `Session`.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SessionState {
|
||||
pub(crate) approved_commands: HashSet<Vec<String>>,
|
||||
pub(crate) current_task: Option<AgentTask>,
|
||||
pub(crate) pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pub(crate) pending_input: Vec<ResponseInputItem>,
|
||||
pub(crate) history: ConversationHistory,
|
||||
pub(crate) token_info: Option<TokenUsageInfo>,
|
||||
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
/// Create a new session state mirroring previous `State::default()` semantics.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
history: ConversationHistory::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// History helpers
|
||||
pub(crate) fn record_items<I>(&mut self, items: I)
|
||||
where
|
||||
I: IntoIterator,
|
||||
I::Item: std::ops::Deref<Target = ResponseItem>,
|
||||
{
|
||||
self.history.record_items(items)
|
||||
}
|
||||
|
||||
pub(crate) fn history_snapshot(&self) -> Vec<ResponseItem> {
|
||||
self.history.contents()
|
||||
}
|
||||
|
||||
pub(crate) fn replace_history(&mut self, items: Vec<ResponseItem>) {
|
||||
self.history.replace(items);
|
||||
}
|
||||
|
||||
// Approved command helpers
|
||||
pub(crate) fn add_approved_command(&mut self, cmd: Vec<String>) {
|
||||
self.approved_commands.insert(cmd);
|
||||
}
|
||||
|
||||
pub(crate) fn approved_commands_ref(&self) -> &HashSet<Vec<String>> {
|
||||
&self.approved_commands
|
||||
}
|
||||
|
||||
// Token/rate limit helpers
|
||||
pub(crate) fn update_token_info_from_usage(
|
||||
&mut self,
|
||||
usage: &TokenUsage,
|
||||
model_context_window: Option<u64>,
|
||||
) {
|
||||
self.token_info = TokenUsageInfo::new_or_append(
|
||||
&self.token_info,
|
||||
&Some(usage.clone()),
|
||||
model_context_window,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
|
||||
self.latest_rate_limits = Some(snapshot);
|
||||
}
|
||||
|
||||
pub(crate) fn token_info_and_rate_limits(
|
||||
&self,
|
||||
) -> (Option<TokenUsageInfo>, Option<RateLimitSnapshot>) {
|
||||
(self.token_info.clone(), self.latest_rate_limits.clone())
|
||||
}
|
||||
|
||||
// Pending input/approval helpers
|
||||
pub(crate) fn insert_pending_approval(
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<ReviewDecision>,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.insert(key, tx)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_approval(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<ReviewDecision>> {
|
||||
self.pending_approvals.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending_approvals.clear();
|
||||
self.pending_input.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
|
||||
self.pending_input.push(input);
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
|
||||
if self.pending_input.is_empty() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut ret = Vec::new();
|
||||
std::mem::swap(&mut ret, &mut self.pending_input);
|
||||
ret
|
||||
}
|
||||
}
|
||||
}
|
||||
9
codex-rs/core/src/state/turn.rs
Normal file
9
codex-rs/core/src/state/turn.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Turn-scoped state and active turn metadata scaffolding.
|
||||
|
||||
/// Metadata about the currently running turn.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ActiveTurn;
|
||||
|
||||
/// Mutable state for a single turn.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TurnState;
|
||||
@@ -1,4 +1,45 @@
|
||||
use serde::Serialize;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct UserNotifier {
|
||||
notify_command: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl UserNotifier {
|
||||
pub(crate) fn notify(&self, notification: &UserNotification) {
|
||||
if let Some(notify_command) = &self.notify_command
|
||||
&& !notify_command.is_empty()
|
||||
{
|
||||
self.invoke_notify(notify_command, notification)
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke_notify(&self, notify_command: &[String], notification: &UserNotification) {
|
||||
let Ok(json) = serde_json::to_string(¬ification) else {
|
||||
error!("failed to serialise notification payload");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut command = std::process::Command::new(¬ify_command[0]);
|
||||
if notify_command.len() > 1 {
|
||||
command.args(¬ify_command[1..]);
|
||||
}
|
||||
command.arg(json);
|
||||
|
||||
// Fire-and-forget – we do not wait for completion.
|
||||
if let Err(e) = command.spawn() {
|
||||
warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new(notify: Option<Vec<String>>) -> Self {
|
||||
Self {
|
||||
notify_command: notify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User can configure a program that will receive notifications. Each
|
||||
/// notification is serialized as JSON and passed as an argument to the
|
||||
@@ -21,9 +62,10 @@ pub(crate) enum UserNotification {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
|
||||
#[test]
|
||||
fn test_user_notification() {
|
||||
fn test_user_notification() -> Result<()> {
|
||||
let notification = UserNotification::AgentTurnComplete {
|
||||
turn_id: "12345".to_string(),
|
||||
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
|
||||
@@ -31,10 +73,11 @@ mod tests {
|
||||
"Rename complete and verified `cargo build` succeeds.".to_string(),
|
||||
),
|
||||
};
|
||||
let serialized = serde_json::to_string(¬ification).unwrap();
|
||||
let serialized = serde_json::to_string(¬ification)?;
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#"{"type":"agent-turn-complete","turn-id":"12345","input-messages":["Rename `foo` to `bar` and update the callsites."],"last-assistant-message":"Rename complete and verified `cargo build` succeeds."}"#
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "core_test_support"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
|
||||
pub mod responses;
|
||||
pub mod test_codex;
|
||||
|
||||
/// Returns a default `Config` whose on-disk state is confined to the provided
|
||||
/// temporary directory. Using a per-test directory keeps tests hermetic and
|
||||
|
||||
75
codex-rs/core/tests/common/test_codex.rs
Normal file
75
codex-rs/core/tests/common/test_codex.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use std::mem::swap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::load_default_config_for_test;
|
||||
|
||||
type ConfigMutator = dyn FnOnce(&mut Config);
|
||||
|
||||
pub struct TestCodexBuilder {
|
||||
config_mutators: Vec<Box<ConfigMutator>>,
|
||||
}
|
||||
|
||||
impl TestCodexBuilder {
|
||||
pub fn with_config<T>(mut self, mutator: T) -> Self
|
||||
where
|
||||
T: FnOnce(&mut Config) + 'static,
|
||||
{
|
||||
self.config_mutators.push(Box::new(mutator));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result<TestCodex> {
|
||||
// Build config pointing to the mock server and spawn Codex.
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&home);
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.model_provider = model_provider;
|
||||
let mut mutators = vec![];
|
||||
swap(&mut self.config_mutators, &mut mutators);
|
||||
|
||||
for mutator in mutators {
|
||||
mutator(&mut config)
|
||||
}
|
||||
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy"));
|
||||
let NewConversation {
|
||||
conversation,
|
||||
session_configured,
|
||||
..
|
||||
} = conversation_manager.new_conversation(config).await?;
|
||||
|
||||
Ok(TestCodex {
|
||||
home,
|
||||
cwd,
|
||||
codex: conversation,
|
||||
session_configured,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestCodex {
|
||||
pub home: TempDir,
|
||||
pub cwd: TempDir,
|
||||
pub codex: Arc<CodexConversation>,
|
||||
pub session_configured: SessionConfiguredEvent,
|
||||
}
|
||||
|
||||
pub fn test_codex() -> TestCodexBuilder {
|
||||
TestCodexBuilder {
|
||||
config_mutators: vec![],
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
@@ -797,7 +798,33 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await;
|
||||
let first_token_event =
|
||||
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await;
|
||||
let rate_limit_only = match first_token_event {
|
||||
EventMsg::TokenCount(ev) => ev,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let rate_limit_json = serde_json::to_value(&rate_limit_only).unwrap();
|
||||
pretty_assertions::assert_eq!(
|
||||
rate_limit_json,
|
||||
json!({
|
||||
"info": null,
|
||||
"rate_limits": {
|
||||
"primary_used_percent": 12.5,
|
||||
"secondary_used_percent": 40.0,
|
||||
"primary_to_secondary_ratio_percent": 75.0,
|
||||
"primary_window_minutes": 10,
|
||||
"secondary_window_minutes": 60
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let token_event = wait_for_event(
|
||||
&codex,
|
||||
|msg| matches!(msg, EventMsg::TokenCount(ev) if ev.info.is_some()),
|
||||
)
|
||||
.await;
|
||||
let final_payload = match token_event {
|
||||
EventMsg::TokenCount(ev) => ev,
|
||||
_ => unreachable!(),
|
||||
@@ -822,7 +849,7 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 123
|
||||
},
|
||||
// Default model is gpt-5 in tests → 272000 context window
|
||||
// Default model is gpt-5-codex in tests → 272000 context window
|
||||
"model_context_window": 272000
|
||||
},
|
||||
"rate_limits": {
|
||||
@@ -846,6 +873,80 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let response = ResponseTemplate::new(429)
|
||||
.insert_header("x-codex-primary-used-percent", "100.0")
|
||||
.insert_header("x-codex-secondary-used-percent", "87.5")
|
||||
.insert_header("x-codex-primary-over-secondary-limit-percent", "95.0")
|
||||
.insert_header("x-codex-primary-window-minutes", "15")
|
||||
.insert_header("x-codex-secondary-window-minutes", "60")
|
||||
.set_body_json(json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
"message": "limit reached",
|
||||
"resets_in_seconds": 42,
|
||||
"plan_type": "pro"
|
||||
}
|
||||
}));
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex();
|
||||
let codex_fixture = builder.build(&server).await?;
|
||||
let codex = codex_fixture.codex.clone();
|
||||
|
||||
let expected_limits = json!({
|
||||
"primary_used_percent": 100.0,
|
||||
"secondary_used_percent": 87.5,
|
||||
"primary_to_secondary_ratio_percent": 95.0,
|
||||
"primary_window_minutes": 15,
|
||||
"secondary_window_minutes": 60
|
||||
});
|
||||
|
||||
let submission_id = codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("submission should succeed while emitting usage limit error events");
|
||||
|
||||
let token_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await;
|
||||
let EventMsg::TokenCount(event) = token_event else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
let event_json = serde_json::to_value(&event).expect("serialize token count event");
|
||||
pretty_assertions::assert_eq!(
|
||||
event_json,
|
||||
json!({
|
||||
"info": null,
|
||||
"rate_limits": expected_limits
|
||||
})
|
||||
);
|
||||
|
||||
let error_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::Error(_))).await;
|
||||
let EventMsg::Error(error_event) = error_event else {
|
||||
unreachable!();
|
||||
};
|
||||
assert!(
|
||||
error_event.message.to_lowercase().contains("usage limit"),
|
||||
"unexpected error message for submission {submission_id}: {}",
|
||||
error_event.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
|
||||
|
||||
@@ -19,6 +19,7 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
use codex_core::codex::compact::SUMMARIZATION_PROMPT;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -37,7 +38,6 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
pub(super) const FIRST_REPLY: &str = "FIRST_REPLY";
|
||||
pub(super) const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT";
|
||||
pub(super) const SUMMARIZE_TRIGGER: &str = "Start Summarization";
|
||||
const THIRD_USER_MSG: &str = "next turn";
|
||||
const AUTO_SUMMARY_TEXT: &str = "AUTO_SUMMARY";
|
||||
const FIRST_AUTO_MSG: &str = "token limit start";
|
||||
@@ -77,13 +77,13 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
let first_matcher = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains("\"text\":\"hello world\"")
|
||||
&& !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
|
||||
&& !body.contains("You have exceeded the maximum number of tokens")
|
||||
};
|
||||
mount_sse_once(&server, first_matcher, sse1).await;
|
||||
|
||||
let second_matcher = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
|
||||
body.contains("You have exceeded the maximum number of tokens")
|
||||
};
|
||||
mount_sse_once(&server, second_matcher, sse2).await;
|
||||
|
||||
@@ -121,7 +121,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// 2) Summarize – second hit with summarization instructions.
|
||||
// 2) Summarize – second hit should include the summarization prompt.
|
||||
codex.submit(Op::Compact).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
@@ -148,16 +148,12 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
let body2 = req2.body_json::<serde_json::Value>().unwrap();
|
||||
let body3 = req3.body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
// System instructions should change for the summarization turn.
|
||||
// Manual compact should keep the baseline developer instructions.
|
||||
let instr1 = body1.get("instructions").and_then(|v| v.as_str()).unwrap();
|
||||
let instr2 = body2.get("instructions").and_then(|v| v.as_str()).unwrap();
|
||||
assert_ne!(
|
||||
assert_eq!(
|
||||
instr1, instr2,
|
||||
"summarization should override base instructions"
|
||||
);
|
||||
assert!(
|
||||
instr2.contains("You have exceeded the maximum number of tokens"),
|
||||
"summarization instructions not applied"
|
||||
"manual compact should keep the standard developer instructions"
|
||||
);
|
||||
|
||||
// The summarization request should include the injected user input marker.
|
||||
@@ -167,14 +163,14 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
assert_eq!(last2.get("type").unwrap().as_str().unwrap(), "message");
|
||||
assert_eq!(last2.get("role").unwrap().as_str().unwrap(), "user");
|
||||
let text2 = last2["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text2.contains(SUMMARIZE_TRIGGER),
|
||||
assert_eq!(
|
||||
text2, SUMMARIZATION_PROMPT,
|
||||
"expected summarize trigger, got `{text2}`"
|
||||
);
|
||||
|
||||
// Third request must contain the refreshed instructions, bridge summary message and new user msg.
|
||||
let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap();
|
||||
println!("third request body: {body3}");
|
||||
|
||||
assert!(
|
||||
input3.len() >= 3,
|
||||
"expected refreshed context and new user message in third request"
|
||||
@@ -215,13 +211,13 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
"bridge should capture earlier user messages"
|
||||
);
|
||||
assert!(
|
||||
!bridge_text.contains(SUMMARIZE_TRIGGER),
|
||||
!bridge_text.contains(SUMMARIZATION_PROMPT),
|
||||
"bridge text should not echo the summarize trigger"
|
||||
);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|(_, text)| text.contains(SUMMARIZE_TRIGGER)),
|
||||
.any(|(_, text)| text.contains(SUMMARIZATION_PROMPT)),
|
||||
"third request should not include the summarize trigger"
|
||||
);
|
||||
|
||||
@@ -395,6 +391,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
"auto compact should add a third request"
|
||||
);
|
||||
|
||||
let body_first = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let body3 = requests[auto_compact_index]
|
||||
.body_json::<serde_json::Value>()
|
||||
.unwrap();
|
||||
@@ -402,9 +399,32 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
instructions.contains("You have exceeded the maximum number of tokens"),
|
||||
"auto compact should reuse summarization instructions"
|
||||
let baseline_instructions = body_first
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
instructions, baseline_instructions,
|
||||
"auto compact should keep the standard developer instructions",
|
||||
);
|
||||
|
||||
let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap();
|
||||
let last3 = input3
|
||||
.last()
|
||||
.expect("auto compact request should append a user message");
|
||||
assert_eq!(last3.get("type").and_then(|v| v.as_str()), Some("message"));
|
||||
assert_eq!(last3.get("role").and_then(|v| v.as_str()), Some("user"));
|
||||
let last_text = last3
|
||||
.get("content")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("text"))
|
||||
.and_then(|text| text.as_str())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(
|
||||
last_text, SUMMARIZATION_PROMPT,
|
||||
"auto compact should send the summarization prompt as a user message",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -635,13 +655,25 @@ async fn auto_compact_stops_after_failed_attempt() {
|
||||
);
|
||||
|
||||
let last_body = requests[2].body_json::<serde_json::Value>().unwrap();
|
||||
let instructions = last_body
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let input = last_body
|
||||
.get("input")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or_else(|| panic!("unexpected request format: {last_body}"));
|
||||
let contains_prompt = input.iter().any(|item| {
|
||||
item.get("type").and_then(|v| v.as_str()) == Some("message")
|
||||
&& item.get("role").and_then(|v| v.as_str()) == Some("user")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|entry| entry.get("text"))
|
||||
.and_then(|text| text.as_str())
|
||||
.map(|text| text == SUMMARIZATION_PROMPT)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(
|
||||
!instructions.contains("You have exceeded the maximum number of tokens"),
|
||||
"third request should be the follow-up turn, not another summarization"
|
||||
!contains_prompt,
|
||||
"third request should be the follow-up turn, not another summarization",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -785,7 +817,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
|
||||
);
|
||||
assert!(
|
||||
request_bodies[1].contains("You have exceeded the maximum number of tokens"),
|
||||
"first auto compact request should use summarization instructions"
|
||||
"first auto compact request should include the summarization prompt"
|
||||
);
|
||||
assert!(
|
||||
request_bodies[3].contains(&format!("unsupported call: {DUMMY_FUNCTION_NAME}")),
|
||||
@@ -793,6 +825,6 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
|
||||
);
|
||||
assert!(
|
||||
request_bodies[4].contains("You have exceeded the maximum number of tokens"),
|
||||
"second auto compact request should reuse summarization instructions"
|
||||
"second auto compact request should include the summarization prompt"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
//! model-visible history matches the expected sequence of messages.
|
||||
|
||||
use super::compact::FIRST_REPLY;
|
||||
use super::compact::SUMMARIZE_TRIGGER;
|
||||
use super::compact::SUMMARY_TEXT;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexConversation;
|
||||
@@ -16,6 +15,7 @@ use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::codex::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -133,7 +133,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
.to_string();
|
||||
let user_turn_1 = json!(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
{
|
||||
@@ -182,12 +182,8 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
});
|
||||
let compact_1 = json!(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"instructions": "You have exceeded the maximum number of tokens, please stop coding and instead write a short memento message for the next agent. Your note should:
|
||||
- Summarize what you finished and what still needs work. If there was a recent update_plan call, repeat its steps verbatim.
|
||||
- List outstanding TODOs with file paths / line numbers so they're easy to find.
|
||||
- Flag code that needs more tests (edge cases, performance, integration, etc.).
|
||||
- Record any open bugs, quirks, or setup steps that will make it easier for the next agent to pick up where you left off.",
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
@@ -235,7 +231,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "Start Summarization"
|
||||
"text": SUMMARIZATION_PROMPT
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,7 +251,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
|
||||
});
|
||||
let user_turn_2_after_compact = json!(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
{
|
||||
@@ -320,7 +316,7 @@ SUMMARY_ONLY_CONTEXT"
|
||||
});
|
||||
let usert_turn_3_after_resume = json!(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
{
|
||||
@@ -405,7 +401,7 @@ SUMMARY_ONLY_CONTEXT"
|
||||
});
|
||||
let user_turn_3_after_fork = json!(
|
||||
{
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5-codex",
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
{
|
||||
@@ -488,13 +484,14 @@ SUMMARY_ONLY_CONTEXT"
|
||||
],
|
||||
"prompt_cache_key": fork_prompt_cache_key
|
||||
});
|
||||
let expected = json!([
|
||||
let mut expected = json!([
|
||||
user_turn_1,
|
||||
compact_1,
|
||||
user_turn_2_after_compact,
|
||||
usert_turn_3_after_resume,
|
||||
user_turn_3_after_fork
|
||||
]);
|
||||
normalize_line_endings(&mut expected);
|
||||
assert_eq!(requests.len(), 5);
|
||||
assert_eq!(json!(requests), expected);
|
||||
}
|
||||
@@ -580,7 +577,7 @@ async fn compact_resume_after_second_compaction_preserves_history() {
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let expected = json!([
|
||||
let mut expected = json!([
|
||||
{
|
||||
"instructions": prompt,
|
||||
"input": [
|
||||
@@ -637,6 +634,7 @@ async fn compact_resume_after_second_compaction_preserves_history() {
|
||||
],
|
||||
}
|
||||
]);
|
||||
normalize_line_endings(&mut expected);
|
||||
let last_request_after_2_compacts = json!([{
|
||||
"instructions": requests[requests.len() -1]["instructions"],
|
||||
"input": requests[requests.len() -1]["input"],
|
||||
@@ -698,7 +696,8 @@ async fn mount_initial_flow(server: &MockServer) {
|
||||
let match_first = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains("\"text\":\"hello world\"")
|
||||
&& !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
|
||||
&& !body.contains("You have exceeded the maximum number of tokens")
|
||||
&& !body.contains(&format!("\"text\":\"{SUMMARY_TEXT}\""))
|
||||
&& !body.contains("\"text\":\"AFTER_COMPACT\"")
|
||||
&& !body.contains("\"text\":\"AFTER_RESUME\"")
|
||||
&& !body.contains("\"text\":\"AFTER_FORK\"")
|
||||
@@ -707,7 +706,7 @@ async fn mount_initial_flow(server: &MockServer) {
|
||||
|
||||
let match_compact = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\""))
|
||||
body.contains("You have exceeded the maximum number of tokens")
|
||||
};
|
||||
mount_sse_once(server, match_compact, sse2).await;
|
||||
|
||||
@@ -741,7 +740,8 @@ async fn mount_second_compact_flow(server: &MockServer) {
|
||||
|
||||
let match_second_compact = |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) && body.contains("AFTER_FORK")
|
||||
body.contains("You have exceeded the maximum number of tokens")
|
||||
&& body.contains("AFTER_FORK")
|
||||
};
|
||||
mount_sse_once(server, match_second_compact, sse6).await;
|
||||
|
||||
|
||||
97
codex-rs/core/tests/suite/json_result.rs
Normal file
97
codex-rs/core/tests/suite/json_result.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use responses::ev_assistant_message;
|
||||
use responses::ev_completed;
|
||||
use responses::sse;
|
||||
use responses::start_mock_server;
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"explanation": { "type": "string" },
|
||||
"final_answer": { "type": "string" }
|
||||
},
|
||||
"required": ["explanation", "final_answer"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_returns_json_result() -> anyhow::Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let sse1 = sse(vec![
|
||||
ev_assistant_message(
|
||||
"m2",
|
||||
r#"{"explanation": "explanation", "final_answer": "final_answer"}"#,
|
||||
),
|
||||
ev_completed("r1"),
|
||||
]);
|
||||
|
||||
let expected_schema: serde_json::Value = serde_json::from_str(SCHEMA)?;
|
||||
let match_json_text_param = move |req: &wiremock::Request| {
|
||||
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
|
||||
let Some(text) = body.get("text") else {
|
||||
return false;
|
||||
};
|
||||
let Some(format) = text.get("format") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
format.get("name") == Some(&serde_json::Value::String("codex_output_schema".into()))
|
||||
&& format.get("type") == Some(&serde_json::Value::String("json_schema".into()))
|
||||
&& format.get("strict") == Some(&serde_json::Value::Bool(true))
|
||||
&& format.get("schema") == Some(&expected_schema)
|
||||
};
|
||||
responses::mount_sse_once(&server, match_json_text_param, sse1).await;
|
||||
|
||||
let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?;
|
||||
|
||||
// 1) Normal user input – should hit server once.
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello world".into(),
|
||||
}],
|
||||
final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "gpt-5".to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let message = wait_for_event(&codex, |ev| matches!(ev, EventMsg::AgentMessage(_))).await;
|
||||
if let EventMsg::AgentMessage(message) = message {
|
||||
let json: serde_json::Value = serde_json::from_str(&message.message)?;
|
||||
assert_eq!(
|
||||
json.get("explanation"),
|
||||
Some(&serde_json::Value::String("explanation".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("final_answer"),
|
||||
Some(&serde_json::Value::String("final_answer".into()))
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!("expected agent message event");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod compact_resume_fork;
|
||||
mod exec;
|
||||
mod exec_stream_events;
|
||||
mod fork_conversation;
|
||||
mod json_result;
|
||||
mod live_cli;
|
||||
mod model_overrides;
|
||||
mod prompt_caching;
|
||||
@@ -15,3 +16,4 @@ mod rollout_list_find;
|
||||
mod seatbelt;
|
||||
mod stream_error_allows_next_turn;
|
||||
mod stream_no_completed;
|
||||
mod user_notification;
|
||||
|
||||
@@ -184,6 +184,7 @@ async fn prompt_tools_are_consistent_across_requests() {
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let expected_instructions = config.model_family.base_instructions.clone();
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
@@ -213,7 +214,6 @@ async fn prompt_tools_are_consistent_across_requests() {
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2, "expected two POST requests");
|
||||
|
||||
let expected_instructions: &str = include_str!("../../prompt.md");
|
||||
// our internal implementation is responsible for keeping tools in sync
|
||||
// with the OpenAI schema, so we just verify the tool presence here
|
||||
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch", "view_image"];
|
||||
@@ -546,6 +546,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -655,6 +656,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -671,6 +673,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -766,6 +769,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -782,6 +786,7 @@ async fn send_user_turn_with_changes_sends_environment_context() {
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -78,18 +76,14 @@ async fn continue_after_stream_error() {
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&home);
|
||||
config.base_instructions = Some("You are a helpful assistant".to_string());
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.base_instructions = Some("You are a helpful assistant".to_string());
|
||||
config.model_provider = provider;
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.unwrap()
|
||||
.conversation;
|
||||
.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use tempfile::TempDir;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -74,7 +73,7 @@ async fn retries_on_early_close() {
|
||||
// provider is not set.
|
||||
env_key: Some("PATH".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: codex_core::WireApi::Responses,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
@@ -85,16 +84,13 @@ async fn retries_on_early_close() {
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider = model_provider;
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.unwrap()
|
||||
.conversation;
|
||||
.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
|
||||
73
codex-rs/core/tests/suite/user_notification.rs
Normal file
73
codex-rs/core/tests/suite/user_notification.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use core_test_support::non_sandbox_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
use responses::ev_assistant_message;
|
||||
use responses::ev_completed;
|
||||
use responses::sse;
|
||||
use responses::start_mock_server;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let sse1 = sse(vec![ev_assistant_message("m1", "Done"), ev_completed("r1")]);
|
||||
|
||||
responses::mount_sse_once(&server, any(), sse1).await;
|
||||
|
||||
let notify_dir = TempDir::new()?;
|
||||
// write a script to the notify that touches a file next to it
|
||||
let notify_script = notify_dir.path().join("notify.sh");
|
||||
std::fs::write(
|
||||
¬ify_script,
|
||||
r#"#!/bin/bash
|
||||
set -e
|
||||
echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
|
||||
)?;
|
||||
std::fs::set_permissions(¬ify_script, std::fs::Permissions::from_mode(0o755))?;
|
||||
|
||||
let notify_file = notify_dir.path().join("notify.txt");
|
||||
let notify_script_str = notify_script.to_str().unwrap().to_string();
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |cfg| cfg.notify = Some(vec![notify_script_str]))
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
// 1) Normal user input – should hit server once.
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello world".into(),
|
||||
}],
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// We fork the notify script, so we need to wait for it to write to the file.
|
||||
for _ in 0..100u32 {
|
||||
if notify_file.exists() {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
assert!(notify_file.exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -51,7 +51,7 @@ Start a new session with optional overrides:
|
||||
|
||||
Request `newConversation` params (subset):
|
||||
|
||||
- `model`: string model id (e.g. "o3", "gpt-5")
|
||||
- `model`: string model id (e.g. "o3", "gpt-5", "gpt-5-codex")
|
||||
- `profile`: optional named profile
|
||||
- `cwd`: optional working directory
|
||||
- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never`
|
||||
@@ -120,4 +120,3 @@ While processing, the server emits `codex/event` notifications containing agent
|
||||
## Compatibility and stability
|
||||
|
||||
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`.
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct Cli {
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Path to a JSON Schema file describing the model's final response shape.
|
||||
#[arg(long = "output-schema", value_name = "FILE")]
|
||||
pub output_schema: Option<PathBuf>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
@@ -63,6 +67,10 @@ pub struct Cli {
|
||||
#[arg(long = "json", default_value_t = false)]
|
||||
pub json: bool,
|
||||
|
||||
/// Whether to include the plan tool in the conversation.
|
||||
#[arg(long = "include-plan-tool", default_value_t = false)]
|
||||
pub include_plan_tool: bool,
|
||||
|
||||
/// Specifies file where the last message from the agent should be written.
|
||||
#[arg(long = "output-last-message")]
|
||||
pub last_message_file: Option<PathBuf>,
|
||||
|
||||
@@ -539,8 +539,37 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
EventMsg::PlanUpdate(plan_update_event) => {
|
||||
let UpdatePlanArgs { explanation, plan } = plan_update_event;
|
||||
ts_println!(self, "explanation: {explanation:?}");
|
||||
ts_println!(self, "plan: {plan:?}");
|
||||
|
||||
// Header
|
||||
ts_println!(self, "{}", "Plan update".style(self.magenta));
|
||||
|
||||
// Optional explanation
|
||||
if let Some(explanation) = explanation
|
||||
&& !explanation.trim().is_empty()
|
||||
{
|
||||
ts_println!(self, "{}", explanation.style(self.italic));
|
||||
}
|
||||
|
||||
// Pretty-print the plan items with simple status markers.
|
||||
for item in plan {
|
||||
use codex_core::plan_tool::StepStatus;
|
||||
match item.status {
|
||||
StepStatus::Completed => {
|
||||
ts_println!(self, " {} {}", "✓".style(self.green), item.step);
|
||||
}
|
||||
StepStatus::InProgress => {
|
||||
ts_println!(self, " {} {}", "→".style(self.cyan), item.step);
|
||||
}
|
||||
StepStatus::Pending => {
|
||||
ts_println!(
|
||||
self,
|
||||
" {} {}",
|
||||
"•".style(self.dimmed),
|
||||
item.step.style(self.dimmed)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::GetHistoryEntryResponse(_) => {
|
||||
// Currently ignored in exec output.
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_json_output::EventProcessorWithJsonOutput;
|
||||
use serde_json::Value;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
@@ -51,6 +52,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
json: json_mode,
|
||||
sandbox_mode: sandbox_mode_cli_arg,
|
||||
prompt,
|
||||
output_schema: output_schema_path,
|
||||
include_plan_tool,
|
||||
config_overrides,
|
||||
} = cli;
|
||||
|
||||
@@ -96,6 +99,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
}
|
||||
};
|
||||
|
||||
let output_schema = load_output_schema(output_schema_path);
|
||||
|
||||
let (stdout_with_ansi, stderr_with_ansi) = match color {
|
||||
cli::Color::Always => (true, true),
|
||||
cli::Color::Never => (false, false),
|
||||
@@ -157,7 +162,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
model_provider,
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
include_plan_tool: None,
|
||||
include_plan_tool: Some(include_plan_tool),
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
@@ -193,7 +198,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
// is using.
|
||||
event_processor.print_config_summary(&config, &prompt);
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&config.cwd.to_path_buf()).is_none() {
|
||||
let default_cwd = config.cwd.to_path_buf();
|
||||
let default_approval_policy = config.approval_policy;
|
||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let default_model = config.model.clone();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() {
|
||||
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -288,7 +300,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
|
||||
// Send the prompt.
|
||||
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
|
||||
let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
|
||||
let initial_prompt_task_id = conversation
|
||||
.submit(Op::UserTurn {
|
||||
items,
|
||||
cwd: default_cwd,
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy,
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await?;
|
||||
info!("Sent prompt with event ID: {initial_prompt_task_id}");
|
||||
|
||||
// Run the loop until the task is complete.
|
||||
@@ -327,3 +350,29 @@ async fn resolve_resume_path(
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
let path = path?;
|
||||
|
||||
let schema_str = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Failed to read output schema file {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str::<Value>(&schema_str) {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"Output schema file {} is not valid JSON: {err}",
|
||||
path.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod apply_patch;
|
||||
mod common;
|
||||
mod output_schema;
|
||||
mod resume;
|
||||
mod sandbox;
|
||||
|
||||
76
codex-rs/exec/tests/suite/output_schema.rs
Normal file
76
codex-rs/exec/tests/suite/output_schema.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use assert_cmd::prelude::*;
|
||||
use core_test_support::responses;
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::any;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_includes_output_schema_in_request() -> anyhow::Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
|
||||
let schema_contents = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let schema_path = workspace.path().join("schema.json");
|
||||
std::fs::write(&schema_path, serde_json::to_vec_pretty(&schema_contents)?)?;
|
||||
let expected_schema: Value = schema_contents;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp1"}
|
||||
}),
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, any(), body).await;
|
||||
|
||||
Command::cargo_bin("codex-exec")?
|
||||
.current_dir(workspace.path())
|
||||
.env("CODEX_HOME", home.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(workspace.path())
|
||||
.arg("--output-schema")
|
||||
.arg(&schema_path)
|
||||
.arg("-m")
|
||||
.arg("gpt-5")
|
||||
.arg("tell me a joke")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("failed to capture requests");
|
||||
assert_eq!(requests.len(), 1, "expected exactly one request");
|
||||
let payload: Value = serde_json::from_slice(&requests[0].body)?;
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
.expect("request missing text.format field");
|
||||
assert_eq!(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": expected_schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -146,6 +146,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::MatchedArg;
|
||||
use crate::PolicyParser;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
|
||||
fn setup(fake_cp: &Path) -> ExecvChecker {
|
||||
let source = format!(
|
||||
@@ -164,7 +166,7 @@ system_path=[{fake_cp:?}]
|
||||
|
||||
#[test]
|
||||
fn test_check_valid_input_files() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Create an executable file that can be used with the system_path arg.
|
||||
let fake_cp = temp_dir.path().join("cp");
|
||||
@@ -172,14 +174,14 @@ system_path=[{fake_cp:?}]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let fake_cp_file = std::fs::File::create(&fake_cp).unwrap();
|
||||
let mut permissions = fake_cp_file.metadata().unwrap().permissions();
|
||||
let fake_cp_file = std::fs::File::create(&fake_cp)?;
|
||||
let mut permissions = fake_cp_file.metadata()?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake_cp, permissions).unwrap();
|
||||
std::fs::set_permissions(&fake_cp, permissions)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::fs::File::create(&fake_cp).unwrap();
|
||||
std::fs::File::create(&fake_cp)?;
|
||||
}
|
||||
|
||||
// Create root_path and reference to files under the root.
|
||||
@@ -199,7 +201,7 @@ system_path=[{fake_cp:?}]
|
||||
program: "cp".into(),
|
||||
args: vec![source, dest.clone()],
|
||||
};
|
||||
let valid_exec = match checker.r#match(&exec_call)? {
|
||||
let valid_exec = match checker.r#match(&exec_call).map_err(|e| anyhow!("{e:?}"))? {
|
||||
MatchedExec::Match { exec } => exec,
|
||||
unexpected => panic!("Expected a safe exec but got {unexpected:?}"),
|
||||
};
|
||||
@@ -244,7 +246,10 @@ system_path=[{fake_cp:?}]
|
||||
program: "cp".into(),
|
||||
args: vec![root.clone(), root],
|
||||
};
|
||||
let valid_exec_call_folders_as_args = match checker.r#match(&exec_call_folders_as_args)? {
|
||||
let valid_exec_call_folders_as_args = match checker
|
||||
.r#match(&exec_call_folders_as_args)
|
||||
.map_err(|e| anyhow!("{e:?}"))?
|
||||
{
|
||||
MatchedExec::Match { exec } => exec,
|
||||
_ => panic!("Expected a safe exec"),
|
||||
};
|
||||
@@ -266,8 +271,9 @@ system_path=[{fake_cp:?}]
|
||||
0,
|
||||
ArgType::ReadableFile,
|
||||
root_path.parent().unwrap().to_str().unwrap(),
|
||||
)?,
|
||||
MatchedArg::new(1, ArgType::WriteableFile, &dest)?,
|
||||
)
|
||||
.map_err(|e| anyhow!("{e:?}"))?,
|
||||
MatchedArg::new(1, ArgType::WriteableFile, &dest).map_err(|e| anyhow!("{e:?}"))?,
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
20
codex-rs/git-tooling/Cargo.toml
Normal file
20
codex-rs/git-tooling/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "codex-git-tooling"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lib]
|
||||
name = "codex_git_tooling"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
walkdir = "2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
20
codex-rs/git-tooling/README.md
Normal file
20
codex-rs/git-tooling/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# codex-git-tooling
|
||||
|
||||
Helpers for interacting with git.
|
||||
|
||||
```rust,no_run
|
||||
use std::path::Path;
|
||||
|
||||
use codex_git_tooling::{create_ghost_commit, restore_ghost_commit, CreateGhostCommitOptions};
|
||||
|
||||
let repo = Path::new("/path/to/repo");
|
||||
|
||||
// Capture the current working tree as an unreferenced commit.
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
|
||||
|
||||
// Later, undo back to that state.
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
```
|
||||
|
||||
Pass a custom message with `.message("…")` or force-include ignored files with
|
||||
`.force_include(["ignored.log".into()])`.
|
||||
35
codex-rs/git-tooling/src/errors.rs
Normal file
35
codex-rs/git-tooling/src/errors.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitStatus;
|
||||
use std::string::FromUtf8Error;
|
||||
|
||||
use thiserror::Error;
|
||||
use walkdir::Error as WalkdirError;
|
||||
|
||||
/// Errors returned while managing git worktree snapshots.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GitToolingError {
|
||||
#[error("git command `{command}` failed with status {status}: {stderr}")]
|
||||
GitCommand {
|
||||
command: String,
|
||||
status: ExitStatus,
|
||||
stderr: String,
|
||||
},
|
||||
#[error("git command `{command}` produced non-UTF-8 output")]
|
||||
GitOutputUtf8 {
|
||||
command: String,
|
||||
#[source]
|
||||
source: FromUtf8Error,
|
||||
},
|
||||
#[error("{path:?} is not a git repository")]
|
||||
NotAGitRepository { path: PathBuf },
|
||||
#[error("path {path:?} must be relative to the repository root")]
|
||||
NonRelativePath { path: PathBuf },
|
||||
#[error("path {path:?} escapes the repository root")]
|
||||
PathEscapesRepository { path: PathBuf },
|
||||
#[error("failed to process path inside worktree")]
|
||||
PathPrefix(#[from] std::path::StripPrefixError),
|
||||
#[error(transparent)]
|
||||
Walkdir(#[from] WalkdirError),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
494
codex-rs/git-tooling/src/ghost_commits.rs
Normal file
494
codex-rs/git-tooling/src/ghost_commits.rs
Normal file
@@ -0,0 +1,494 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tempfile::Builder;
|
||||
|
||||
use crate::GhostCommit;
|
||||
use crate::GitToolingError;
|
||||
use crate::operations::apply_repo_prefix_to_force_include;
|
||||
use crate::operations::ensure_git_repository;
|
||||
use crate::operations::normalize_relative_path;
|
||||
use crate::operations::repo_subdir;
|
||||
use crate::operations::resolve_head;
|
||||
use crate::operations::resolve_repository_root;
|
||||
use crate::operations::run_git_for_status;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
|
||||
/// Default commit message used for ghost commits when none is provided.
|
||||
const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot";
|
||||
|
||||
/// Options to control ghost commit creation.
|
||||
pub struct CreateGhostCommitOptions<'a> {
|
||||
pub repo_path: &'a Path,
|
||||
pub message: Option<&'a str>,
|
||||
pub force_include: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl<'a> CreateGhostCommitOptions<'a> {
|
||||
/// Creates options scoped to the provided repository path.
|
||||
pub fn new(repo_path: &'a Path) -> Self {
|
||||
Self {
|
||||
repo_path,
|
||||
message: None,
|
||||
force_include: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a custom commit message for the ghost commit.
|
||||
pub fn message(mut self, message: &'a str) -> Self {
|
||||
self.message = Some(message);
|
||||
self
|
||||
}
|
||||
|
||||
/// Supplies the entire force-include path list at once.
|
||||
pub fn force_include<I>(mut self, paths: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = PathBuf>,
|
||||
{
|
||||
self.force_include = paths.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a single path to the force-include list.
|
||||
pub fn push_force_include<P>(mut self, path: P) -> Self
|
||||
where
|
||||
P: Into<PathBuf>,
|
||||
{
|
||||
self.force_include.push(path.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a ghost commit capturing the current state of the repository's working tree.
|
||||
pub fn create_ghost_commit(
|
||||
options: &CreateGhostCommitOptions<'_>,
|
||||
) -> Result<GhostCommit, GitToolingError> {
|
||||
ensure_git_repository(options.repo_path)?;
|
||||
|
||||
let repo_root = resolve_repository_root(options.repo_path)?;
|
||||
let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
|
||||
let parent = resolve_head(repo_root.as_path())?;
|
||||
|
||||
let normalized_force = options
|
||||
.force_include
|
||||
.iter()
|
||||
.map(|path| normalize_relative_path(path))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let force_include =
|
||||
apply_repo_prefix_to_force_include(repo_prefix.as_deref(), &normalized_force);
|
||||
let index_tempdir = Builder::new().prefix("codex-git-index-").tempdir()?;
|
||||
let index_path = index_tempdir.path().join("index");
|
||||
let base_env = vec![(
|
||||
OsString::from("GIT_INDEX_FILE"),
|
||||
OsString::from(index_path.as_os_str()),
|
||||
)];
|
||||
|
||||
let mut add_args = vec![OsString::from("add"), OsString::from("--all")];
|
||||
if let Some(prefix) = repo_prefix.as_deref() {
|
||||
add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]);
|
||||
}
|
||||
|
||||
run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?;
|
||||
if !force_include.is_empty() {
|
||||
let mut args = Vec::with_capacity(force_include.len() + 2);
|
||||
args.push(OsString::from("add"));
|
||||
args.push(OsString::from("--force"));
|
||||
args.extend(
|
||||
force_include
|
||||
.iter()
|
||||
.map(|path| OsString::from(path.as_os_str())),
|
||||
);
|
||||
run_git_for_status(repo_root.as_path(), args, Some(base_env.as_slice()))?;
|
||||
}
|
||||
|
||||
let tree_id = run_git_for_stdout(
|
||||
repo_root.as_path(),
|
||||
vec![OsString::from("write-tree")],
|
||||
Some(base_env.as_slice()),
|
||||
)?;
|
||||
|
||||
let mut commit_env = base_env;
|
||||
commit_env.extend(default_commit_identity());
|
||||
let message = options.message.unwrap_or(DEFAULT_COMMIT_MESSAGE);
|
||||
let commit_args = {
|
||||
let mut result = vec![OsString::from("commit-tree"), OsString::from(&tree_id)];
|
||||
if let Some(parent) = parent.as_deref() {
|
||||
result.extend([OsString::from("-p"), OsString::from(parent)]);
|
||||
}
|
||||
result.extend([OsString::from("-m"), OsString::from(message)]);
|
||||
result
|
||||
};
|
||||
|
||||
// Retrieve commit ID.
|
||||
let commit_id = run_git_for_stdout(
|
||||
repo_root.as_path(),
|
||||
commit_args,
|
||||
Some(commit_env.as_slice()),
|
||||
)?;
|
||||
|
||||
Ok(GhostCommit::new(commit_id, parent))
|
||||
}
|
||||
|
||||
/// Restore the working tree to match the provided ghost commit.
|
||||
pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> {
|
||||
restore_to_commit(repo_path, commit.id())
|
||||
}
|
||||
|
||||
/// Restore the working tree to match the given commit ID.
|
||||
pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToolingError> {
|
||||
ensure_git_repository(repo_path)?;
|
||||
|
||||
let repo_root = resolve_repository_root(repo_path)?;
|
||||
let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
|
||||
|
||||
let mut restore_args = vec![
|
||||
OsString::from("restore"),
|
||||
OsString::from("--source"),
|
||||
OsString::from(commit_id),
|
||||
OsString::from("--worktree"),
|
||||
OsString::from("--staged"),
|
||||
OsString::from("--"),
|
||||
];
|
||||
if let Some(prefix) = repo_prefix.as_deref() {
|
||||
restore_args.push(prefix.as_os_str().to_os_string());
|
||||
} else {
|
||||
restore_args.push(OsString::from("."));
|
||||
}
|
||||
|
||||
run_git_for_status(repo_root.as_path(), restore_args, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the default author and committer identity for ghost commits.
|
||||
fn default_commit_identity() -> Vec<(OsString, OsString)> {
|
||||
vec![
|
||||
(
|
||||
OsString::from("GIT_AUTHOR_NAME"),
|
||||
OsString::from("Codex Snapshot"),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_AUTHOR_EMAIL"),
|
||||
OsString::from("snapshot@codex.local"),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_COMMITTER_NAME"),
|
||||
OsString::from("Codex Snapshot"),
|
||||
),
|
||||
(
|
||||
OsString::from("GIT_COMMITTER_EMAIL"),
|
||||
OsString::from("snapshot@codex.local"),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::operations::run_git_for_stdout;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::process::Command;
|
||||
|
||||
/// Runs a git command in the test repository and asserts success.
|
||||
fn run_git_in(repo_path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(repo_path)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git command");
|
||||
assert!(status.success(), "git command failed: {args:?}");
|
||||
}
|
||||
|
||||
/// Runs a git command and returns its trimmed stdout output.
|
||||
fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(repo_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git command");
|
||||
assert!(output.status.success(), "git command failed: {args:?}");
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
/// Initializes a repository with consistent settings for cross-platform tests.
|
||||
fn init_test_repo(repo: &Path) {
|
||||
run_git_in(repo, &["init", "--initial-branch=main"]);
|
||||
run_git_in(repo, &["config", "core.autocrlf", "false"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a ghost commit can be created and restored end to end.
|
||||
fn create_and_restore_roundtrip() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
std::fs::write(repo.join("tracked.txt"), "initial\n")?;
|
||||
std::fs::write(repo.join("delete-me.txt"), "to be removed\n")?;
|
||||
run_git_in(repo, &["add", "tracked.txt", "delete-me.txt"]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
],
|
||||
);
|
||||
|
||||
let tracked_contents = "modified contents\n";
|
||||
std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
|
||||
std::fs::remove_file(repo.join("delete-me.txt"))?;
|
||||
let new_file_contents = "hello ghost\n";
|
||||
std::fs::write(repo.join("new-file.txt"), new_file_contents)?;
|
||||
std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
|
||||
let ignored_contents = "ignored but captured\n";
|
||||
std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
|
||||
|
||||
let options =
|
||||
CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
|
||||
let ghost = create_ghost_commit(&options)?;
|
||||
|
||||
assert!(ghost.parent().is_some());
|
||||
let cat = run_git_for_stdout(
|
||||
repo,
|
||||
vec![
|
||||
OsString::from("show"),
|
||||
OsString::from(format!("{}:ignored.txt", ghost.id())),
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
assert_eq!(cat, ignored_contents.trim());
|
||||
|
||||
std::fs::write(repo.join("tracked.txt"), "other state\n")?;
|
||||
std::fs::write(repo.join("ignored.txt"), "changed\n")?;
|
||||
std::fs::remove_file(repo.join("new-file.txt"))?;
|
||||
std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?;
|
||||
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
|
||||
let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
|
||||
assert_eq!(tracked_after, tracked_contents);
|
||||
let ignored_after = std::fs::read_to_string(repo.join("ignored.txt"))?;
|
||||
assert_eq!(ignored_after, ignored_contents);
|
||||
let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?;
|
||||
assert_eq!(new_file_after, new_file_contents);
|
||||
assert_eq!(repo.join("delete-me.txt").exists(), false);
|
||||
assert!(repo.join("ephemeral.txt").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Ensures ghost commits succeed in repositories without an existing HEAD.
|
||||
fn create_snapshot_without_existing_head() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
let tracked_contents = "first contents\n";
|
||||
std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
|
||||
let ignored_contents = "ignored but captured\n";
|
||||
std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
|
||||
std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
|
||||
|
||||
let options =
|
||||
CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
|
||||
let ghost = create_ghost_commit(&options)?;
|
||||
|
||||
assert!(ghost.parent().is_none());
|
||||
|
||||
let message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
|
||||
assert_eq!(message, DEFAULT_COMMIT_MESSAGE);
|
||||
|
||||
let ignored = run_git_stdout(repo, &["show", &format!("{}:ignored.txt", ghost.id())]);
|
||||
assert_eq!(ignored, ignored_contents.trim());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Confirms custom messages are used when creating ghost commits.
|
||||
fn create_ghost_commit_uses_custom_message() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
std::fs::write(repo.join("tracked.txt"), "contents\n")?;
|
||||
run_git_in(repo, &["add", "tracked.txt"]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
let message = "custom message";
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo).message(message))?;
|
||||
let commit_message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
|
||||
assert_eq!(commit_message, message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Rejects force-included paths that escape the repository.
|
||||
fn create_ghost_commit_rejects_force_include_parent_path() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
let options = CreateGhostCommitOptions::new(repo)
|
||||
.force_include(vec![PathBuf::from("../outside.txt")]);
|
||||
let err = create_ghost_commit(&options).unwrap_err();
|
||||
assert!(matches!(err, GitToolingError::PathEscapesRepository { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Restoring a ghost commit from a non-git directory fails.
|
||||
fn restore_requires_git_repository() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err();
|
||||
assert!(matches!(err, GitToolingError::NotAGitRepository { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Restoring from a subdirectory affects only that subdirectory.
|
||||
fn restore_from_subdirectory_restores_files_relatively() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
std::fs::create_dir_all(repo.join("workspace"))?;
|
||||
let workspace = repo.join("workspace");
|
||||
std::fs::write(repo.join("root.txt"), "root contents\n")?;
|
||||
std::fs::write(workspace.join("nested.txt"), "nested contents\n")?;
|
||||
run_git_in(repo, &["add", "."]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
std::fs::write(repo.join("root.txt"), "root modified\n")?;
|
||||
std::fs::write(workspace.join("nested.txt"), "nested modified\n")?;
|
||||
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
|
||||
|
||||
std::fs::write(repo.join("root.txt"), "root after\n")?;
|
||||
std::fs::write(workspace.join("nested.txt"), "nested after\n")?;
|
||||
|
||||
restore_ghost_commit(&workspace, &ghost)?;
|
||||
|
||||
let root_after = std::fs::read_to_string(repo.join("root.txt"))?;
|
||||
assert_eq!(root_after, "root after\n");
|
||||
let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?;
|
||||
assert_eq!(nested_after, "nested modified\n");
|
||||
assert!(!workspace.join("codex-rs").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Restoring from a subdirectory preserves ignored files in parent folders.
|
||||
fn restore_from_subdirectory_preserves_parent_vscode() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
let workspace = repo.join("codex-rs");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
|
||||
std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?;
|
||||
run_git_in(repo, &["add", "."]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
std::fs::write(workspace.join("tracked.txt"), "snapshot delta\n")?;
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
|
||||
|
||||
std::fs::write(workspace.join("tracked.txt"), "post-snapshot\n")?;
|
||||
let vscode = repo.join(".vscode");
|
||||
std::fs::create_dir_all(&vscode)?;
|
||||
std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?;
|
||||
|
||||
restore_ghost_commit(&workspace, &ghost)?;
|
||||
|
||||
let tracked_after = std::fs::read_to_string(workspace.join("tracked.txt"))?;
|
||||
assert_eq!(tracked_after, "snapshot delta\n");
|
||||
assert!(vscode.join("settings.json").exists());
|
||||
let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
|
||||
assert_eq!(settings_after, "{\n \"after\": true\n}\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Restoring from the repository root keeps ignored files intact.
|
||||
fn restore_preserves_ignored_files() -> Result<(), GitToolingError> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let repo = temp.path();
|
||||
init_test_repo(repo);
|
||||
|
||||
std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
|
||||
std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?;
|
||||
let vscode = repo.join(".vscode");
|
||||
std::fs::create_dir_all(&vscode)?;
|
||||
std::fs::write(vscode.join("settings.json"), "{\n \"before\": true\n}\n")?;
|
||||
run_git_in(repo, &["add", ".gitignore", "tracked.txt"]);
|
||||
run_git_in(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Tester",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
);
|
||||
|
||||
std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?;
|
||||
let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
|
||||
|
||||
std::fs::write(repo.join("tracked.txt"), "post-snapshot\n")?;
|
||||
std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?;
|
||||
std::fs::write(repo.join("temp.txt"), "new file\n")?;
|
||||
|
||||
restore_ghost_commit(repo, &ghost)?;
|
||||
|
||||
let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
|
||||
assert_eq!(tracked_after, "snapshot delta\n");
|
||||
assert!(vscode.join("settings.json").exists());
|
||||
let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
|
||||
assert_eq!(settings_after, "{\n \"after\": true\n}\n");
|
||||
assert!(repo.join("temp.txt").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
43
codex-rs/git-tooling/src/lib.rs
Normal file
43
codex-rs/git-tooling/src/lib.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::fmt;
|
||||
|
||||
mod errors;
|
||||
mod ghost_commits;
|
||||
mod operations;
|
||||
mod platform;
|
||||
|
||||
pub use errors::GitToolingError;
|
||||
pub use ghost_commits::CreateGhostCommitOptions;
|
||||
pub use ghost_commits::create_ghost_commit;
|
||||
pub use ghost_commits::restore_ghost_commit;
|
||||
pub use ghost_commits::restore_to_commit;
|
||||
pub use platform::create_symlink;
|
||||
|
||||
/// Details of a ghost commit created from a repository state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GhostCommit {
|
||||
id: String,
|
||||
parent: Option<String>,
|
||||
}
|
||||
|
||||
impl GhostCommit {
|
||||
/// Create a new ghost commit wrapper from a raw commit ID and optional parent.
|
||||
pub fn new(id: String, parent: Option<String>) -> Self {
|
||||
Self { id, parent }
|
||||
}
|
||||
|
||||
/// Commit ID for the snapshot.
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Parent commit ID, if the repository had a `HEAD` at creation time.
|
||||
pub fn parent(&self) -> Option<&str> {
|
||||
self.parent.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GhostCommit {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.id)
|
||||
}
|
||||
}
|
||||
218
codex-rs/git-tooling/src/operations.rs
Normal file
218
codex-rs/git-tooling/src/operations.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::GitToolingError;
|
||||
|
||||
pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> {
|
||||
match run_git_for_stdout(
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
OsString::from("--is-inside-work-tree"),
|
||||
],
|
||||
None,
|
||||
) {
|
||||
Ok(output) if output.trim() == "true" => Ok(()),
|
||||
Ok(_) => Err(GitToolingError::NotAGitRepository {
|
||||
path: path.to_path_buf(),
|
||||
}),
|
||||
Err(GitToolingError::GitCommand { status, .. }) if status.code() == Some(128) => {
|
||||
Err(GitToolingError::NotAGitRepository {
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_head(path: &Path) -> Result<Option<String>, GitToolingError> {
|
||||
match run_git_for_stdout(
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
OsString::from("--verify"),
|
||||
OsString::from("HEAD"),
|
||||
],
|
||||
None,
|
||||
) {
|
||||
Ok(sha) => Ok(Some(sha)),
|
||||
Err(GitToolingError::GitCommand { status, .. }) if status.code() == Some(128) => Ok(None),
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let mut result = PathBuf::new();
|
||||
let mut saw_component = false;
|
||||
for component in path.components() {
|
||||
saw_component = true;
|
||||
match component {
|
||||
Component::Normal(part) => result.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !result.pop() {
|
||||
return Err(GitToolingError::PathEscapesRepository {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(GitToolingError::NonRelativePath {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_component {
|
||||
return Err(GitToolingError::NonRelativePath {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_repository_root(path: &Path) -> Result<PathBuf, GitToolingError> {
|
||||
let root = run_git_for_stdout(
|
||||
path,
|
||||
vec![
|
||||
OsString::from("rev-parse"),
|
||||
OsString::from("--show-toplevel"),
|
||||
],
|
||||
None,
|
||||
)?;
|
||||
Ok(PathBuf::from(root))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_repo_prefix_to_force_include(
|
||||
prefix: Option<&Path>,
|
||||
paths: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
if paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
match prefix {
|
||||
Some(prefix) => paths.iter().map(|path| prefix.join(path)).collect(),
|
||||
None => paths.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repo_subdir(repo_root: &Path, repo_path: &Path) -> Option<PathBuf> {
|
||||
if repo_root == repo_path {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo_path
|
||||
.strip_prefix(repo_root)
|
||||
.ok()
|
||||
.and_then(non_empty_path)
|
||||
.or_else(|| {
|
||||
let repo_root_canon = repo_root.canonicalize().ok()?;
|
||||
let repo_path_canon = repo_path.canonicalize().ok()?;
|
||||
repo_path_canon
|
||||
.strip_prefix(&repo_root_canon)
|
||||
.ok()
|
||||
.and_then(non_empty_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn non_empty_path(path: &Path) -> Option<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_for_status<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<(), GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
run_git(dir, args, env)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run_git_for_stdout<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<String, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let run = run_git(dir, args, env)?;
|
||||
String::from_utf8(run.output.stdout)
|
||||
.map(|value| value.trim().to_string())
|
||||
.map_err(|source| GitToolingError::GitOutputUtf8 {
|
||||
command: run.command,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git<I, S>(
|
||||
dir: &Path,
|
||||
args: I,
|
||||
env: Option<&[(OsString, OsString)]>,
|
||||
) -> Result<GitRun, GitToolingError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let iterator = args.into_iter();
|
||||
let (lower, upper) = iterator.size_hint();
|
||||
let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower));
|
||||
for arg in iterator {
|
||||
args_vec.push(OsString::from(arg.as_ref()));
|
||||
}
|
||||
let command_string = build_command_string(&args_vec);
|
||||
let mut command = Command::new("git");
|
||||
command.current_dir(dir);
|
||||
if let Some(envs) = env {
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
}
|
||||
command.args(&args_vec);
|
||||
let output = command.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(GitToolingError::GitCommand {
|
||||
command: command_string,
|
||||
status: output.status,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
Ok(GitRun {
|
||||
command: command_string,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_command_string(args: &[OsString]) -> String {
|
||||
if args.is_empty() {
|
||||
return "git".to_string();
|
||||
}
|
||||
let joined = args
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("git {joined}")
|
||||
}
|
||||
|
||||
struct GitRun {
|
||||
command: String,
|
||||
output: std::process::Output,
|
||||
}
|
||||
37
codex-rs/git-tooling/src/platform.rs
Normal file
37
codex-rs/git-tooling/src/platform.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::GitToolingError;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn create_symlink(
|
||||
_source: &Path,
|
||||
link_target: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), GitToolingError> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
symlink(link_target, destination)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn create_symlink(
|
||||
source: &Path,
|
||||
link_target: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), GitToolingError> {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
use std::os::windows::fs::symlink_file;
|
||||
|
||||
let metadata = std::fs::symlink_metadata(source)?;
|
||||
if metadata.file_type().is_symlink_dir() {
|
||||
symlink_dir(link_target, destination)?;
|
||||
} else {
|
||||
symlink_file(link_target, destination)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
compile_error!("codex-git-tooling symlink support is only implemented for Unix and Windows");
|
||||
@@ -30,5 +30,6 @@ urlencoding = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::run_login_server;
|
||||
@@ -76,13 +77,13 @@ fn start_mock_issuer() -> (SocketAddr, thread::JoinHandle<()>) {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn end_to_end_login_flow_persists_auth_json() {
|
||||
non_sandbox_test!();
|
||||
async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let (issuer_addr, issuer_handle) = start_mock_issuer();
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().to_path_buf();
|
||||
|
||||
// Seed auth.json with stale API key + tokens that should be overwritten.
|
||||
@@ -97,9 +98,8 @@ async fn end_to_end_login_flow_persists_auth_json() {
|
||||
});
|
||||
std::fs::write(
|
||||
codex_home.join("auth.json"),
|
||||
serde_json::to_string_pretty(&stale_auth).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
serde_json::to_string_pretty(&stale_auth)?,
|
||||
)?;
|
||||
|
||||
let state = "test_state_123".to_string();
|
||||
|
||||
@@ -114,25 +114,24 @@ async fn end_to_end_login_flow_persists_auth_json() {
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
let server = run_login_server(opts).unwrap();
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
|
||||
// Simulate browser callback, and follow redirect to /success
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
.build()?;
|
||||
let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123");
|
||||
let resp = client.get(&url).send().await.unwrap();
|
||||
let resp = client.get(&url).send().await?;
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Wait for server shutdown
|
||||
server.block_until_done().await.unwrap();
|
||||
server.block_until_done().await?;
|
||||
|
||||
// Validate auth.json
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
let data = std::fs::read_to_string(&auth_path).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&data).unwrap();
|
||||
let data = std::fs::read_to_string(&auth_path)?;
|
||||
let json: serde_json::Value = serde_json::from_str(&data)?;
|
||||
// The following assert is here because of the old oauth flow that exchanges tokens for an
|
||||
// API key. See obtain_api_key in server.rs for details. Once we remove this old mechanism
|
||||
// from the code, this test should be updated to expect that the API key is no longer present.
|
||||
@@ -143,16 +142,17 @@ async fn end_to_end_login_flow_persists_auth_json() {
|
||||
|
||||
// Stop mock issuer
|
||||
drop(issuer_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_missing_codex_home_dir() {
|
||||
non_sandbox_test!();
|
||||
async fn creates_missing_codex_home_dir() -> Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let (issuer_addr, _issuer_handle) = start_mock_issuer();
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("missing-subdir"); // does not exist
|
||||
|
||||
let state = "state2".to_string();
|
||||
@@ -167,31 +167,32 @@ async fn creates_missing_codex_home_dir() {
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
let server = run_login_server(opts).unwrap();
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=state2");
|
||||
let resp = client.get(&url).send().await.unwrap();
|
||||
let resp = client.get(&url).send().await?;
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
server.block_until_done().await.unwrap();
|
||||
server.block_until_done().await?;
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
assert!(
|
||||
auth_path.exists(),
|
||||
"auth.json should be created even if parent dir was missing"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn cancels_previous_login_server_when_port_is_in_use() {
|
||||
non_sandbox_test!();
|
||||
async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
|
||||
non_sandbox_test!(result);
|
||||
|
||||
let (issuer_addr, _issuer_handle) = start_mock_issuer();
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let first_tmp = tempdir().unwrap();
|
||||
let first_tmp = tempdir()?;
|
||||
let first_codex_home = first_tmp.path().to_path_buf();
|
||||
|
||||
let first_opts = ServerOptions {
|
||||
@@ -203,13 +204,13 @@ async fn cancels_previous_login_server_when_port_is_in_use() {
|
||||
force_state: Some("cancel_state".to_string()),
|
||||
};
|
||||
|
||||
let first_server = run_login_server(first_opts).unwrap();
|
||||
let first_server = run_login_server(first_opts)?;
|
||||
let login_port = first_server.actual_port;
|
||||
let first_server_task = tokio::spawn(async move { first_server.block_until_done().await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let second_tmp = tempdir().unwrap();
|
||||
let second_tmp = tempdir()?;
|
||||
let second_codex_home = second_tmp.path().to_path_buf();
|
||||
|
||||
let second_opts = ServerOptions {
|
||||
@@ -221,7 +222,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() {
|
||||
force_state: Some("cancel_state_2".to_string()),
|
||||
};
|
||||
|
||||
let second_server = run_login_server(second_opts).unwrap();
|
||||
let second_server = run_login_server(second_opts)?;
|
||||
assert_eq!(second_server.actual_port, login_port);
|
||||
|
||||
let cancel_result = first_server_task
|
||||
@@ -232,11 +233,12 @@ async fn cancels_previous_login_server_when_port_is_in_use() {
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let cancel_url = format!("http://127.0.0.1:{login_port}/cancel");
|
||||
let resp = client.get(cancel_url).send().await.unwrap();
|
||||
let resp = client.get(cancel_url).send().await?;
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
second_server
|
||||
.block_until_done()
|
||||
.await
|
||||
.expect_err("second login server should report cancellation");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1009,6 +1009,7 @@ impl CodexMessageProcessor {
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -1410,13 +1411,13 @@ fn extract_conversation_summary(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_conversation_summary_prefers_plain_user_messages() {
|
||||
let conversation_id =
|
||||
ConversationId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap();
|
||||
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
|
||||
let conversation_id = ConversationId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;
|
||||
let timestamp = Some("2025-09-05T16:53:11.850Z".to_string());
|
||||
let path = PathBuf::from("rollout.jsonl");
|
||||
|
||||
@@ -1456,5 +1457,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(summary.path, path);
|
||||
assert_eq!(summary.preview, "Count to 5");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ pub(crate) struct OutgoingError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Result;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
@@ -269,12 +270,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_event_as_notification() {
|
||||
async fn test_send_event_as_notification() -> Result<()> {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let rollout_file = NamedTempFile::new()?;
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
@@ -302,15 +303,16 @@ mod tests {
|
||||
panic!("Event must serialize");
|
||||
};
|
||||
assert_eq!(params, Some(expected_params));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_event_as_notification_with_meta() {
|
||||
async fn test_send_event_as_notification_with_meta() -> Result<()> {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let rollout_file = NamedTempFile::new()?;
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "gpt-4o".to_string(),
|
||||
@@ -353,6 +355,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
assert_eq!(params.unwrap(), expected_params);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
approval_policy = "on-request"
|
||||
sandbox_mode = "workspace-write"
|
||||
model_reasoning_summary = "detailed"
|
||||
@@ -92,7 +92,7 @@ async fn get_config_toml_parses_all_fields() {
|
||||
exclude_tmpdir_env_var: Some(true),
|
||||
exclude_slash_tmp: Some(true),
|
||||
}),
|
||||
model: Some("gpt-5".into()),
|
||||
model: Some("gpt-5-codex".into()),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
|
||||
@@ -69,7 +69,7 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "gpt-5"
|
||||
model = "gpt-5-codex"
|
||||
model_reasoning_effort = "medium"
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ ts-rs = { workspace = true, features = [
|
||||
uuid = { workspace = true, features = ["serde", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
|
||||
@@ -702,15 +702,16 @@ impl ServerNotification {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn serialize_new_conversation() {
|
||||
fn serialize_new_conversation() -> Result<()> {
|
||||
let request = ClientRequest::NewConversation {
|
||||
request_id: RequestId::Integer(42),
|
||||
params: NewConversationParams {
|
||||
model: Some("gpt-5".to_string()),
|
||||
model: Some("gpt-5-codex".to_string()),
|
||||
profile: None,
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
@@ -726,12 +727,13 @@ mod tests {
|
||||
"method": "newConversation",
|
||||
"id": 42,
|
||||
"params": {
|
||||
"model": "gpt-5",
|
||||
"model": "gpt-5-codex",
|
||||
"approvalPolicy": "on-request"
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request).unwrap(),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -741,23 +743,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_serializes_as_plain_string() {
|
||||
let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
|
||||
fn conversation_id_serializes_as_plain_string() -> Result<()> {
|
||||
let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
||||
|
||||
assert_eq!(
|
||||
json!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
|
||||
serde_json::to_value(id).unwrap()
|
||||
serde_json::to_value(id)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_deserializes_from_plain_string() {
|
||||
fn conversation_id_deserializes_from_plain_string() -> Result<()> {
|
||||
let id: ConversationId =
|
||||
serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8")).unwrap();
|
||||
serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?;
|
||||
|
||||
assert_eq!(
|
||||
ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(),
|
||||
ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?,
|
||||
id,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +318,10 @@ impl std::ops::Deref for FunctionCallOutputPayload {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
|
||||
#[test]
|
||||
fn serializes_success_as_plain_string() {
|
||||
fn serializes_success_as_plain_string() -> Result<()> {
|
||||
let item = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call1".into(),
|
||||
output: FunctionCallOutputPayload {
|
||||
@@ -329,15 +330,16 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let json = serde_json::to_string(&item)?;
|
||||
let v: serde_json::Value = serde_json::from_str(&json)?;
|
||||
|
||||
// Success case -> output should be a plain string
|
||||
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_failure_as_string() {
|
||||
fn serializes_failure_as_string() -> Result<()> {
|
||||
let item = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call1".into(),
|
||||
output: FunctionCallOutputPayload {
|
||||
@@ -346,21 +348,22 @@ mod tests {
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let json = serde_json::to_string(&item)?;
|
||||
let v: serde_json::Value = serde_json::from_str(&json)?;
|
||||
|
||||
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_shell_tool_call_params() {
|
||||
fn deserialize_shell_tool_call_params() -> Result<()> {
|
||||
let json = r#"{
|
||||
"command": ["ls", "-l"],
|
||||
"workdir": "/tmp",
|
||||
"timeout": 1000
|
||||
}"#;
|
||||
|
||||
let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
|
||||
let params: ShellToolCallParams = serde_json::from_str(json)?;
|
||||
assert_eq!(
|
||||
ShellToolCallParams {
|
||||
command: vec!["ls".to_string(), "-l".to_string()],
|
||||
@@ -371,5 +374,6 @@ mod tests {
|
||||
},
|
||||
params
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use mcp_types::CallToolResult;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_with::serde_as;
|
||||
use strum_macros::Display;
|
||||
use ts_rs::TS;
|
||||
@@ -87,6 +88,8 @@ pub enum Op {
|
||||
|
||||
/// Will only be honored if the model is configured to use reasoning.
|
||||
summary: ReasoningSummaryConfig,
|
||||
// The JSON schema to use for the final assistant message
|
||||
final_output_json_schema: Option<Value>,
|
||||
},
|
||||
|
||||
/// Override parts of the persistent turn context for subsequent turns.
|
||||
@@ -589,11 +592,11 @@ impl TokenUsageInfo {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TokenCountEvent {
|
||||
pub info: Option<TokenUsageInfo>,
|
||||
pub rate_limits: Option<RateLimitSnapshotEvent>,
|
||||
pub rate_limits: Option<RateLimitSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct RateLimitSnapshotEvent {
|
||||
pub struct RateLimitSnapshot {
|
||||
/// Percentage (0-100) of the primary window that has been consumed.
|
||||
pub primary_used_percent: f64,
|
||||
/// Percentage (0-100) of the secondary window that has been consumed.
|
||||
@@ -1261,16 +1264,16 @@ pub enum TurnAbortReason {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use serde_json::json;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
fn serialize_event() {
|
||||
let conversation_id =
|
||||
ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
fn serialize_event() -> Result<()> {
|
||||
let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
||||
let rollout_file = NamedTempFile::new()?;
|
||||
let event = Event {
|
||||
id: "1234".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
@@ -1296,23 +1299,25 @@ mod tests {
|
||||
"rollout_path": format!("{}", rollout_file.path().display()),
|
||||
}
|
||||
});
|
||||
assert_eq!(expected, serde_json::to_value(&event).unwrap());
|
||||
assert_eq!(expected, serde_json::to_value(&event)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec_u8_as_base64_serialization_and_deserialization() {
|
||||
fn vec_u8_as_base64_serialization_and_deserialization() -> Result<()> {
|
||||
let event = ExecCommandOutputDeltaEvent {
|
||||
call_id: "call21".to_string(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: vec![1, 2, 3, 4, 5],
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
let serialized = serde_json::to_string(&event)?;
|
||||
assert_eq!(
|
||||
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
|
||||
serialized,
|
||||
);
|
||||
|
||||
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap();
|
||||
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized)?;
|
||||
assert_eq!(deserialized, event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ codex-common = { workspace = true, features = [
|
||||
] }
|
||||
codex-core = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-git-tooling = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
|
||||
@@ -30,7 +30,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
@@ -92,7 +92,8 @@ mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
//
|
||||
use std::path::Path;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
@@ -103,7 +104,13 @@ use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::path::Path;
|
||||
use codex_git_tooling::CreateGhostCommitOptions;
|
||||
use codex_git_tooling::GhostCommit;
|
||||
use codex_git_tooling::GitToolingError;
|
||||
use codex_git_tooling::create_ghost_commit;
|
||||
use codex_git_tooling::restore_ghost_commit;
|
||||
|
||||
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
@@ -111,7 +118,7 @@ struct RunningCommand {
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
|
||||
#[derive(Default)]
|
||||
struct RateLimitWarningState {
|
||||
@@ -125,26 +132,36 @@ impl RateLimitWarningState {
|
||||
secondary_used_percent: f64,
|
||||
primary_used_percent: f64,
|
||||
) -> Vec<String> {
|
||||
if secondary_used_percent == 100.0 || primary_used_percent == 100.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let mut highest_secondary: Option<f64> = None;
|
||||
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
|
||||
{
|
||||
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index];
|
||||
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_secondary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your weekly limit. Run /status for a breakdown."
|
||||
));
|
||||
self.secondary_index += 1;
|
||||
}
|
||||
|
||||
let mut highest_primary: Option<f64> = None;
|
||||
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
|
||||
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
|
||||
{
|
||||
let threshold = RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index];
|
||||
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
|
||||
self.primary_index += 1;
|
||||
}
|
||||
if let Some(threshold) = highest_primary {
|
||||
warnings.push(format!(
|
||||
"Heads up, you've used over {threshold:.0}% of your 5h limit. Run /status for a breakdown."
|
||||
));
|
||||
self.primary_index += 1;
|
||||
}
|
||||
|
||||
warnings
|
||||
@@ -172,7 +189,7 @@ pub(crate) struct ChatWidget {
|
||||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotEvent>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshot>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
// Stream lifecycle controller
|
||||
stream_controller: Option<StreamController>,
|
||||
@@ -197,6 +214,9 @@ pub(crate) struct ChatWidget {
|
||||
pending_notification: Option<Notification>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// List of ghost commits corresponding to each turn.
|
||||
ghost_snapshots: Vec<GhostCommit>,
|
||||
ghost_snapshots_disabled: bool,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -337,11 +357,13 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
self.bottom_pane.set_token_usage(info.clone());
|
||||
self.token_info = info;
|
||||
if info.is_some() {
|
||||
self.bottom_pane.set_token_usage(info.clone());
|
||||
self.token_info = info;
|
||||
}
|
||||
}
|
||||
|
||||
fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshotEvent>) {
|
||||
fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(snapshot) = snapshot {
|
||||
let warnings = self.rate_limit_warnings.take_warnings(
|
||||
snapshot.secondary_used_percent,
|
||||
@@ -787,6 +809,8 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,6 +870,8 @@ impl ChatWidget {
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,6 +1004,9 @@ impl ChatWidget {
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
SlashCommand::Undo => {
|
||||
self.undo_last_snapshot();
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
@@ -1088,6 +1117,12 @@ impl ChatWidget {
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.capture_ghost_snapshot();
|
||||
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -1098,10 +1133,6 @@ impl ChatWidget {
|
||||
items.push(InputItem::LocalImage { path });
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -1123,6 +1154,57 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_ghost_snapshot(&mut self) {
|
||||
if self.ghost_snapshots_disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let options = CreateGhostCommitOptions::new(&self.config.cwd);
|
||||
match create_ghost_commit(&options) {
|
||||
Ok(commit) => {
|
||||
self.ghost_snapshots.push(commit);
|
||||
if self.ghost_snapshots.len() > MAX_TRACKED_GHOST_COMMITS {
|
||||
self.ghost_snapshots.remove(0);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.ghost_snapshots_disabled = true;
|
||||
let (message, hint) = match &err {
|
||||
GitToolingError::NotAGitRepository { .. } => (
|
||||
"Snapshots disabled: current directory is not a Git repository."
|
||||
.to_string(),
|
||||
None,
|
||||
),
|
||||
_ => (
|
||||
format!("Snapshots disabled after error: {err}"),
|
||||
Some(
|
||||
"Restart Codex after resolving the issue to re-enable snapshots."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
};
|
||||
self.add_info_message(message, hint);
|
||||
tracing::warn!("failed to create ghost snapshot: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_last_snapshot(&mut self) {
|
||||
let Some(commit) = self.ghost_snapshots.pop() else {
|
||||
self.add_info_message("No snapshot available to undo.".to_string(), None);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = restore_ghost_commit(&self.config.cwd, &commit) {
|
||||
self.add_error_message(format!("Failed to restore snapshot: {err}"));
|
||||
self.ghost_snapshots.push(commit);
|
||||
return;
|
||||
}
|
||||
|
||||
let short_id: String = commit.id().chars().take(8).collect();
|
||||
self.add_info_message(format!("Restored workspace to snapshot {short_id}"), None);
|
||||
}
|
||||
|
||||
/// Replay a subset of initial events into the UI to seed the transcript when
|
||||
/// resuming an existing session. This approximates the live event flow and
|
||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 648
|
||||
expression: visible_after
|
||||
---
|
||||
> I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
@@ -10,6 +9,14 @@ expression: visible_after
|
||||
└ List ls -la
|
||||
Read Cargo.toml
|
||||
|
||||
• I'm reviewing the workspace's release profile, which has settings like
|
||||
lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it
|
||||
seems that "strip = symbols" only removes symbols, not debug information. In
|
||||
Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I
|
||||
should check whether using these results in a smaller binary. I’ll also skim
|
||||
through crates for heavy dependencies in Cargo.toml, including cli, core,
|
||||
exec, linux-sandbox, tui, login, ollama, and mcp.
|
||||
|
||||
• Ran
|
||||
└ for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
|
||||
@@ -19,6 +26,79 @@ expression: visible_after
|
||||
• Explored
|
||||
└ Read Cargo.toml
|
||||
|
||||
• I’m evaluating how binary size is influenced by various factors. There are
|
||||
many binaries, such as codex and codex-exec, which include dependencies by
|
||||
default, pulling in features like Tokio and hyper. For instance, reqwest
|
||||
includes default features like TLS through rustls. If default features remain
|
||||
enabled, it inflates the size with many additional components. I notice
|
||||
that for musl targets, using vendored builds of OpenSSL could significantly
|
||||
increase binary size. This is quite the intricate web of dependencies!
|
||||
|
||||
Analyzing musl and binary size
|
||||
|
||||
I’m considering the implications of building for musl with static linking.
|
||||
In this case, OpenSSL might come into play through some crates, but reqwest
|
||||
defaults to using rustls, although openssl-sys vendored could increase
|
||||
binary size. It’s interesting to think about whether they’re targeting musl,
|
||||
especially since it seems they might often be building for Mac and Linux with
|
||||
dynamic linking instead.
|
||||
|
||||
I also realize there’s a difference between debug and release builds — release
|
||||
should be smaller due to optimizations, but if they’re defaulting to debug
|
||||
builds, that could lead to much larger binaries.
|
||||
|
||||
Assessing crate impacts on size
|
||||
|
||||
I need to be precise when analyzing binary sizes from various crates. Some
|
||||
crates, like ratatui-image, include heavy features such as libjpeg decoders,
|
||||
which can really bulk things up. Then there are the tree-sitter crates that
|
||||
contain large static grammar tables, adding significant size. The starlark
|
||||
crate and tracing-subscriber also contribute to size increases.
|
||||
|
||||
With static linking per binary being standard in Rust, it leads to multiple
|
||||
copies of the same dependencies unless cdylibs are used. That makes the
|
||||
compile times longer, but using LTO helps reduce bloat somewhat through
|
||||
deduplication.
|
||||
|
||||
Evaluating binary size factors
|
||||
|
||||
Having multiple binaries indeed increases the overall size significantly.
|
||||
I'm considering options like upgrading to "opt-level = 'z'" or using
|
||||
"strip=debuginfo" for reducing size. It's interesting that while the profile
|
||||
only sets symbols for stripping, not debuginfo, this could inflate sizes if
|
||||
retained in release builds.
|
||||
|
||||
The distinction between "symbols" and "debuginfo" is critical; "symbols"
|
||||
keeps debug information which may not align with the goal of minimizing
|
||||
size. I’d like to verify with the Rust documentation, but my network access
|
||||
is restricted.
|
||||
|
||||
Explaining large binary sizes
|
||||
|
||||
I’m analyzing why binaries in this project are so large. The primary reasons
|
||||
include monolithic static linking, where each binary includes the full
|
||||
dependency graph without shared libraries. Heavy dependencies such as reqwest,
|
||||
hyper, and TLS components also contribute significantly to size, along with
|
||||
image decoders and other libraries.
|
||||
|
||||
Using MUSL targets means vendored OpenSSL can drastically increase the
|
||||
size due to static linking. Furthermore, if the release profile keeps debug
|
||||
information due to using "strip = symbols," that can lead to larger binaries
|
||||
as well. It’s a mix of several factors, including panic strategies and
|
||||
platform-specific linking behavior.
|
||||
|
||||
Identifying binary size contributors
|
||||
|
||||
I’m considering why binaries are still large even without static linking. The
|
||||
tracing-subscriber crate potentially adds weight because it includes regex,
|
||||
though it can sometimes use a lighter version. References from files like
|
||||
"core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses
|
||||
the image crate.
|
||||
|
||||
To present this clearly, I’ll format the final answer with two main sections:
|
||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
||||
reducing size, but I want to stay focused on answering the user's question.
|
||||
|
||||
> Here’s what’s driving size in this workspace’s binaries.
|
||||
|
||||
Main Causes
|
||||
|
||||
@@ -335,6 +335,8 @@ fn make_chatwidget_manual() -> (
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
@@ -390,33 +392,22 @@ fn rate_limit_warnings_emit_thresholds() {
|
||||
warnings.extend(state.take_warnings(95.0, 10.0));
|
||||
|
||||
assert_eq!(
|
||||
warnings.len(),
|
||||
6,
|
||||
"expected one warning per threshold per limit"
|
||||
);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Heads up, you've used over 50% of your 5h limit.")),
|
||||
"expected hourly 50% warning (new copy)"
|
||||
);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Heads up, you've used over 50% of your weekly limit.")),
|
||||
"expected weekly 50% warning (new copy)"
|
||||
);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Heads up, you've used over 90% of your 5h limit.")),
|
||||
"expected hourly 90% warning (new copy)"
|
||||
);
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Heads up, you've used over 90% of your weekly limit.")),
|
||||
"expected weekly 90% warning (new copy)"
|
||||
warnings,
|
||||
vec![
|
||||
String::from(
|
||||
"Heads up, you've used over 75% of your 5h limit. Run /status for a breakdown."
|
||||
),
|
||||
String::from(
|
||||
"Heads up, you've used over 75% of your weekly limit. Run /status for a breakdown.",
|
||||
),
|
||||
String::from(
|
||||
"Heads up, you've used over 95% of your 5h limit. Run /status for a breakdown."
|
||||
),
|
||||
String::from(
|
||||
"Heads up, you've used over 95% of your weekly limit. Run /status for a breakdown.",
|
||||
),
|
||||
],
|
||||
"expected one warning per limit for the highest crossed threshold"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ use codex_core::plan_tool::UpdatePlanArgs;
|
||||
use codex_core::project_doc::discover_project_doc_paths;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::RateLimitSnapshotEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -633,6 +633,50 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
|
||||
|
||||
fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
|
||||
if width < 4 {
|
||||
return None;
|
||||
}
|
||||
let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width);
|
||||
Some(inner_width)
|
||||
}
|
||||
|
||||
fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
let content_width = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum::<usize>()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut out = Vec::with_capacity(lines.len() + 2);
|
||||
let border_inner_width = content_width + 2;
|
||||
out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into());
|
||||
|
||||
for line in lines.into_iter() {
|
||||
let used_width: usize = line
|
||||
.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum();
|
||||
let span_count = line.spans.len();
|
||||
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
|
||||
spans.push(Span::from("│ ").dim());
|
||||
spans.extend(line.into_iter());
|
||||
if used_width < content_width {
|
||||
spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
|
||||
}
|
||||
spans.push(Span::from(" │").dim());
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
|
||||
out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into());
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
@@ -816,46 +860,20 @@ impl SessionHeaderHistoryCell {
|
||||
|
||||
impl HistoryCell for SessionHeaderHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
if width < 4 {
|
||||
return out;
|
||||
}
|
||||
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let inner_width = std::cmp::min(
|
||||
width.saturating_sub(2) as usize,
|
||||
SESSION_HEADER_MAX_INNER_WIDTH,
|
||||
);
|
||||
// Top border without a title on the border
|
||||
let mut top = String::with_capacity(inner_width + 2);
|
||||
top.push('╭');
|
||||
top.push_str(&"─".repeat(inner_width));
|
||||
top.push('╮');
|
||||
out.push(Line::from(top.dim()));
|
||||
let make_row = |spans: Vec<Span<'static>>| Line::from(spans);
|
||||
|
||||
// Title line rendered inside the box: " >_ OpenAI Codex (vX)"
|
||||
let title_text = format!(" >_ OpenAI Codex (v{})", self.version);
|
||||
let title_w = UnicodeWidthStr::width(title_text.as_str());
|
||||
let pad_w = inner_width.saturating_sub(title_w);
|
||||
let mut title_spans: Vec<Span<'static>> = vec![
|
||||
Span::from("│").dim(),
|
||||
Span::from(" ").dim(),
|
||||
// Title line rendered inside the box: ">_ OpenAI Codex (vX)"
|
||||
let title_spans: Vec<Span<'static>> = vec![
|
||||
Span::from(">_ ").dim(),
|
||||
Span::from("OpenAI Codex").bold(),
|
||||
Span::from(" ").dim(),
|
||||
Span::from(format!("(v{})", self.version)).dim(),
|
||||
];
|
||||
if pad_w > 0 {
|
||||
title_spans.push(Span::from(" ".repeat(pad_w)).dim());
|
||||
}
|
||||
title_spans.push(Span::from("│").dim());
|
||||
out.push(Line::from(title_spans));
|
||||
|
||||
// Spacer row between title and details
|
||||
out.push(Line::from(vec![
|
||||
Span::from(format!("│{}│", " ".repeat(inner_width))).dim(),
|
||||
]));
|
||||
|
||||
// Model line: " model: <model> <reasoning_label> (change with /model)"
|
||||
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
|
||||
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
|
||||
const DIR_LABEL: &str = "directory:";
|
||||
@@ -866,59 +884,33 @@ impl HistoryCell for SessionHeaderHistoryCell {
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut model_value_for_width = self.model.clone();
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
model_value_for_width.push(' ');
|
||||
model_value_for_width.push_str(reasoning);
|
||||
}
|
||||
let model_text_for_width_calc = format!(
|
||||
" {model_label} {model_value_for_width} {CHANGE_MODEL_HINT_COMMAND}{CHANGE_MODEL_HINT_EXPLANATION}",
|
||||
);
|
||||
let model_w = UnicodeWidthStr::width(model_text_for_width_calc.as_str());
|
||||
let pad_w = inner_width.saturating_sub(model_w);
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::from(format!("│ {model_label} ")).dim(),
|
||||
let mut model_spans: Vec<Span<'static>> = vec![
|
||||
Span::from(format!("{model_label} ")).dim(),
|
||||
Span::from(self.model.clone()),
|
||||
];
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
spans.push(Span::from(" "));
|
||||
spans.push(Span::from(reasoning));
|
||||
model_spans.push(Span::from(" "));
|
||||
model_spans.push(Span::from(reasoning));
|
||||
}
|
||||
spans.push(Span::from(" ").dim());
|
||||
spans.push(Span::from(CHANGE_MODEL_HINT_COMMAND).cyan());
|
||||
spans.push(Span::from(CHANGE_MODEL_HINT_EXPLANATION).dim());
|
||||
if pad_w > 0 {
|
||||
spans.push(Span::from(" ".repeat(pad_w)).dim());
|
||||
}
|
||||
spans.push(Span::from("│").dim());
|
||||
out.push(Line::from(spans));
|
||||
model_spans.push(" ".dim());
|
||||
model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
||||
model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
||||
|
||||
// Directory line: " Directory: <cwd>"
|
||||
let dir_label = format!("{DIR_LABEL:<label_width$}");
|
||||
let dir_prefix = format!(" {dir_label} ");
|
||||
let dir_max_width = inner_width.saturating_sub(UnicodeWidthStr::width(dir_prefix.as_str()));
|
||||
let dir_prefix = format!("{dir_label} ");
|
||||
let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
|
||||
let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
|
||||
let dir = self.format_directory(Some(dir_max_width));
|
||||
let dir_text = format!(" {dir_label} {dir}");
|
||||
let dir_w = UnicodeWidthStr::width(dir_text.as_str());
|
||||
let pad_w = inner_width.saturating_sub(dir_w);
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::from("│").dim(),
|
||||
Span::from(" ").dim(),
|
||||
Span::from(dir_label).dim(),
|
||||
Span::from(" ").dim(),
|
||||
Span::from(dir),
|
||||
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
|
||||
|
||||
let lines = vec![
|
||||
make_row(title_spans),
|
||||
make_row(Vec::new()),
|
||||
make_row(model_spans),
|
||||
make_row(dir_spans),
|
||||
];
|
||||
if pad_w > 0 {
|
||||
spans.push(Span::from(" ".repeat(pad_w)).dim());
|
||||
}
|
||||
spans.push(Span::from("│").dim());
|
||||
out.push(Line::from(spans));
|
||||
|
||||
// Bottom border
|
||||
let bottom = format!("╰{}╯", "─".repeat(inner_width));
|
||||
out.push(Line::from(bottom.dim()));
|
||||
|
||||
out
|
||||
with_border(lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,7 +1082,7 @@ pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
session_id: &Option<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotEvent>,
|
||||
rate_limits: Option<&RateLimitSnapshot>,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push("/status".magenta().into());
|
||||
@@ -1175,7 +1167,13 @@ pub(crate) fn new_status_output(
|
||||
|
||||
// 👤 Account (only if ChatGPT tokens exist), shown under the first block
|
||||
let auth_file = get_auth_file(&config.codex_home);
|
||||
if let Ok(auth) = try_read_auth_json(&auth_file)
|
||||
let auth = try_read_auth_json(&auth_file).ok();
|
||||
let is_chatgpt_auth = auth
|
||||
.as_ref()
|
||||
.and_then(|auth| auth.tokens.as_ref())
|
||||
.is_some();
|
||||
if is_chatgpt_auth
|
||||
&& let Some(auth) = auth.as_ref()
|
||||
&& let Some(tokens) = auth.tokens.clone()
|
||||
{
|
||||
lines.push(vec![padded_emoji("👤").into(), "Account".bold()].into());
|
||||
@@ -1251,8 +1249,10 @@ pub(crate) fn new_status_output(
|
||||
format_with_separators(usage.blended_total()).into(),
|
||||
]));
|
||||
|
||||
lines.push("".into());
|
||||
lines.extend(build_status_limit_lines(rate_limits));
|
||||
if is_chatgpt_auth {
|
||||
lines.push("".into());
|
||||
lines.extend(build_status_limit_lines(rate_limits));
|
||||
}
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
@@ -1611,7 +1611,7 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
invocation_spans.into()
|
||||
}
|
||||
|
||||
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotEvent>) -> Vec<Line<'static>> {
|
||||
fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshot>) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![vec![padded_emoji("⏱️").into(), "Usage Limits".bold()].into()];
|
||||
|
||||
@@ -1630,7 +1630,7 @@ fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotEvent>) -> Vec<Li
|
||||
lines.push(build_status_limit_line(&label, percent, label_width));
|
||||
}
|
||||
}
|
||||
None => lines.push(" • Rate limit data not available yet.".dim().into()),
|
||||
None => lines.push(" • Send a message to load usage data.".into()),
|
||||
}
|
||||
|
||||
lines
|
||||
|
||||
@@ -570,7 +570,6 @@ mod tests {
|
||||
// Create a unique CODEX_HOME per test to isolate auth.json writes.
|
||||
let codex_home = get_next_codex_home();
|
||||
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
|
||||
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum SlashCommand {
|
||||
New,
|
||||
Init,
|
||||
Compact,
|
||||
Undo,
|
||||
Diff,
|
||||
Mention,
|
||||
Status,
|
||||
@@ -35,7 +36,8 @@ impl SlashCommand {
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my changes and find issues",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Undo => "restore the workspace to the last Codex snapshot",
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
@@ -61,6 +63,7 @@ impl SlashCommand {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Undo
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Review
|
||||
@@ -79,5 +82,20 @@ impl SlashCommand {
|
||||
|
||||
/// Return all built-in commands in a Vec paired with their command string.
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
let show_beta_features = beta_features_enabled();
|
||||
|
||||
SlashCommand::iter()
|
||||
.filter(|cmd| {
|
||||
if *cmd == SlashCommand::Undo {
|
||||
show_beta_features
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.map(|c| (c.command(), c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn beta_features_enabled() -> bool {
|
||||
std::env::var_os("BETA_FEATURE").is_some()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5","reasoning_effort":"medium","model_provider_id":"openai","model_provider_name":"OpenAI"}
|
||||
{"ts":"2025-08-09T15:51:04.827Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-5-codex","reasoning_effort":"medium","model_provider_id":"openai","model_provider_name":"OpenAI"}
|
||||
{"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"}
|
||||
{"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"}
|
||||
{"ts":"2025-08-09T15:51:04.827Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('m'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"}
|
||||
@@ -34,7 +34,7 @@
|
||||
{"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
{"ts":"2025-08-09T15:51:04.829Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"}
|
||||
{"ts":"2025-08-09T15:51:04.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
|
||||
{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}}
|
||||
{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"d126e3d0-80ed-480a-be8c-09d97ff602cf","model":"gpt-5-codex","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":339,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}}
|
||||
{"ts":"2025-08-09T15:51:04.856Z","dir":"to_tui","kind":"insert_history","lines":9}
|
||||
{"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
{"ts":"2025-08-09T15:51:04.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
@@ -16447,7 +16447,7 @@
|
||||
{"ts":"2025-08-09T16:06:58.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
{"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
|
||||
{"ts":"2025-08-09T16:06:58.085Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"}
|
||||
{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}}
|
||||
{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"c7df96da-daec-4fe9-aed9-3cd19b7a6192","model":"gpt-5-codex","reasoning_effort":"medium","history_log_id":2532619,"history_entry_count":342,"rollout_path":"/tmp/codex-test-rollout.jsonl"}}}
|
||||
{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"insert_history","lines":9}
|
||||
{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
{"ts":"2025-08-09T16:06:58.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
|
||||
|
||||
16
codex-rs/utils/readiness/Cargo.toml
Normal file
16
codex-rs/utils/readiness/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "codex-utils-readiness"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
249
codex-rs/utils/readiness/src/lib.rs
Normal file
249
codex-rs/utils/readiness/src/lib.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! Readiness flag with token-based authorization and async waiting (Tokio).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time;
|
||||
|
||||
/// Opaque subscription token returned by `subscribe()`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct Token(i32);
|
||||
|
||||
const LOCK_TIMEOUT: Duration = Duration::from_millis(1000);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait Readiness: Send + Sync + 'static {
|
||||
/// Returns true if the flag is currently marked ready. At least one token needs to be marked
|
||||
/// as ready before.
|
||||
/// `true` is not reversible.
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// Subscribe to readiness and receive an authorization token.
|
||||
///
|
||||
/// If the flag is already ready, returns `FlagAlreadyReady`.
|
||||
async fn subscribe(&self) -> Result<Token, errors::ReadinessError>;
|
||||
|
||||
/// Attempt to mark the flag ready, validated by the provided token.
|
||||
///
|
||||
/// Returns `true` iff:
|
||||
/// - `token` is currently subscribed, and
|
||||
/// - the flag was not already ready.
|
||||
async fn mark_ready(&self, token: Token) -> Result<bool, errors::ReadinessError>;
|
||||
|
||||
/// Asynchronously wait until the flag becomes ready.
|
||||
async fn wait_ready(&self);
|
||||
}
|
||||
|
||||
pub struct ReadinessFlag {
|
||||
/// Atomic for cheap reads.
|
||||
ready: AtomicBool,
|
||||
/// Used to generate the next i32 token.
|
||||
next_id: AtomicI32,
|
||||
/// Set of active subscriptions.
|
||||
tokens: Mutex<HashSet<Token>>,
|
||||
/// Broadcasts readiness to async waiters.
|
||||
tx: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl ReadinessFlag {
|
||||
/// Create a new, not-yet-ready flag.
|
||||
pub fn new() -> Self {
|
||||
let (tx, _rx) = watch::channel(false);
|
||||
Self {
|
||||
ready: AtomicBool::new(false),
|
||||
next_id: AtomicI32::new(1), // Reserve 0.
|
||||
tokens: Mutex::new(HashSet::new()),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
async fn with_tokens<R>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut HashSet<Token>) -> R,
|
||||
) -> Result<R, errors::ReadinessError> {
|
||||
let mut guard = time::timeout(LOCK_TIMEOUT, self.tokens.lock())
|
||||
.await
|
||||
.map_err(|_| errors::ReadinessError::TokenLockFailed)?;
|
||||
Ok(f(&mut guard))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReadinessFlag {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Readiness for ReadinessFlag {
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
async fn subscribe(&self) -> Result<Token, errors::ReadinessError> {
|
||||
if self.is_ready() {
|
||||
return Err(errors::ReadinessError::FlagAlreadyReady);
|
||||
}
|
||||
|
||||
// Generate a token; ensure it's not 0.
|
||||
let token = Token(self.next_id.fetch_add(1, Ordering::Relaxed));
|
||||
|
||||
// Recheck readiness while holding the lock so mark_ready can't flip the flag between the
|
||||
// check above and inserting the token.
|
||||
let inserted = self
|
||||
.with_tokens(|tokens| {
|
||||
if self.is_ready() {
|
||||
return false;
|
||||
}
|
||||
tokens.insert(token);
|
||||
true
|
||||
})
|
||||
.await?;
|
||||
|
||||
if !inserted {
|
||||
return Err(errors::ReadinessError::FlagAlreadyReady);
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn mark_ready(&self, token: Token) -> Result<bool, errors::ReadinessError> {
|
||||
if self.is_ready() {
|
||||
return Ok(false);
|
||||
}
|
||||
if token.0 == 0 {
|
||||
return Ok(false); // Never authorize.
|
||||
}
|
||||
|
||||
let marked = self
|
||||
.with_tokens(|set| {
|
||||
if !set.remove(&token) {
|
||||
return false; // invalid or already used
|
||||
}
|
||||
self.ready.store(true, Ordering::Release);
|
||||
set.clear(); // no further tokens needed once ready
|
||||
true
|
||||
})
|
||||
.await?;
|
||||
if !marked {
|
||||
return Ok(false);
|
||||
}
|
||||
// Best-effort broadcast; ignore error if there are no receivers.
|
||||
let _ = self.tx.send(true);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn wait_ready(&self) {
|
||||
if self.is_ready() {
|
||||
return;
|
||||
}
|
||||
let mut rx = self.tx.subscribe();
|
||||
// Fast-path check before awaiting.
|
||||
if *rx.borrow() {
|
||||
return;
|
||||
}
|
||||
// Await changes until true is observed.
|
||||
while rx.changed().await.is_ok() {
|
||||
if *rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod errors {
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ReadinessError {
|
||||
#[error("Failed to acquire readiness token lock")]
|
||||
TokenLockFailed,
|
||||
#[error("Flag is already ready. Impossible to subscribe")]
|
||||
FlagAlreadyReady,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::Readiness;
|
||||
use super::ReadinessFlag;
|
||||
use super::Token;
|
||||
use super::errors::ReadinessError;
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_and_mark_ready_roundtrip() -> Result<(), ReadinessError> {
|
||||
let flag = ReadinessFlag::new();
|
||||
let token = flag.subscribe().await?;
|
||||
|
||||
assert!(flag.mark_ready(token).await?);
|
||||
assert!(flag.is_ready());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_after_ready_returns_none() -> Result<(), ReadinessError> {
|
||||
let flag = ReadinessFlag::new();
|
||||
let token = flag.subscribe().await?;
|
||||
assert!(flag.mark_ready(token).await?);
|
||||
|
||||
assert!(flag.subscribe().await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_ready_rejects_unknown_token() -> Result<(), ReadinessError> {
|
||||
let flag = ReadinessFlag::new();
|
||||
assert!(!flag.mark_ready(Token(42)).await?);
|
||||
assert!(!flag.is_ready());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_ready_unblocks_after_mark_ready() -> Result<(), ReadinessError> {
|
||||
let flag = Arc::new(ReadinessFlag::new());
|
||||
let token = flag.subscribe().await?;
|
||||
|
||||
let waiter = {
|
||||
let flag = Arc::clone(&flag);
|
||||
tokio::spawn(async move {
|
||||
flag.wait_ready().await;
|
||||
})
|
||||
};
|
||||
|
||||
assert!(flag.mark_ready(token).await?);
|
||||
waiter.await.expect("waiting task should not panic");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_ready_twice_uses_single_token() -> Result<(), ReadinessError> {
|
||||
let flag = ReadinessFlag::new();
|
||||
let token = flag.subscribe().await?;
|
||||
|
||||
assert!(flag.mark_ready(token).await?);
|
||||
assert!(!flag.mark_ready(token).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_returns_error_when_lock_is_held() {
|
||||
let flag = ReadinessFlag::new();
|
||||
let _guard = flag
|
||||
.tokens
|
||||
.try_lock()
|
||||
.expect("initial lock acquisition should succeed");
|
||||
|
||||
let err = flag
|
||||
.subscribe()
|
||||
.await
|
||||
.expect_err("contended subscribe should report a lock failure");
|
||||
assert!(matches!(err, ReadinessError::TokenLockFailed));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ Both the `--config` flag and the `config.toml` file support the following option
|
||||
The model that Codex should use.
|
||||
|
||||
```toml
|
||||
model = "o3" # overrides the default of "gpt-5"
|
||||
model = "o3" # overrides the default of "gpt-5-codex"
|
||||
```
|
||||
|
||||
## model_providers
|
||||
@@ -223,11 +223,11 @@ Users can specify config values at multiple levels. Order of precedence is as fo
|
||||
1. custom command-line argument, e.g., `--model o3`
|
||||
2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
|
||||
3. as an entry in `config.toml`, e.g., `model = "o3"`
|
||||
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5`)
|
||||
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5-codex`)
|
||||
|
||||
## model_reasoning_effort
|
||||
|
||||
If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to:
|
||||
If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5`, `gpt-5-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to:
|
||||
|
||||
- `"minimal"`
|
||||
- `"low"`
|
||||
@@ -606,7 +606,7 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
|
||||
|
||||
| Key | Type / Values | Notes |
|
||||
| --- | --- | --- |
|
||||
| `model` | string | Model to use (e.g., `gpt-5`). |
|
||||
| `model` | string | Model to use (e.g., `gpt-5-codex`). |
|
||||
| `model_provider` | string | Provider id from `model_providers` (default: `openai`). |
|
||||
| `model_context_window` | number | Context window tokens. |
|
||||
| `model_max_output_tokens` | number | Max output tokens. |
|
||||
|
||||
Reference in New Issue
Block a user