diff --git a/.codespellrc b/.codespellrc
index f01272c61d..eefde42a4e 100644
--- a/.codespellrc
+++ b/.codespellrc
@@ -1,6 +1,6 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
-skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts
+skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser
diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock
index 82e12cc4b6..15b55fafa7 100644
--- a/.github/actions/codex/bun.lock
+++ b/.github/actions/codex/bun.lock
@@ -8,8 +8,8 @@
"@actions/github": "^6.0.1",
},
"devDependencies": {
- "@types/bun": "^1.2.19",
- "@types/node": "^24.1.0",
+ "@types/bun": "^1.2.20",
+ "@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
},
@@ -48,15 +48,15 @@
"@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
- "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
+ "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
- "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
+ "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
- "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
+ "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -72,7 +72,7 @@
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
- "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
+ "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="],
@@ -82,8 +82,6 @@
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
- "bun-types/@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
-
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="],
diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json
index 6c7ae9002b..208d9fef78 100644
--- a/.github/actions/codex/package.json
+++ b/.github/actions/codex/package.json
@@ -13,8 +13,8 @@
"@actions/github": "^6.0.1"
},
"devDependencies": {
- "@types/bun": "^1.2.19",
- "@types/node": "^24.1.0",
+ "@types/bun": "^1.2.20",
+ "@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2"
}
diff --git a/.github/codex/home/config.toml b/.github/codex/home/config.toml
index bb1b362bb6..8e800ca068 100644
--- a/.github/codex/home/config.toml
+++ b/.github/codex/home/config.toml
@@ -1,3 +1,3 @@
-model = "o3"
+model = "gpt-5"
# Consider setting [mcp_servers] here!
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
index 97075a1f07..0fd67175a9 100644
--- a/.github/workflows/rust-ci.yml
+++ b/.github/workflows/rust-ci.yml
@@ -34,7 +34,7 @@ jobs:
# CI to validate on different os/targets
lint_build_test:
- name: ${{ matrix.runner }} - ${{ matrix.target }}
+ name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
@@ -49,18 +49,31 @@ jobs:
include:
- runner: macos-14
target: aarch64-apple-darwin
+ profile: dev
- runner: macos-14
target: x86_64-apple-darwin
+ profile: dev
+ - runner: macos-14
+ target: aarch64-apple-darwin
+ profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
+ profile: dev
+ - runner: ubuntu-24.04
+ target: x86_64-unknown-linux-musl
+ profile: release
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
+ profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
+ profile: dev
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
+ profile: dev
- runner: windows-latest
target: x86_64-pc-windows-msvc
+ profile: dev
steps:
- uses: actions/checkout@v4
@@ -77,7 +90,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
- key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
+ key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
@@ -86,7 +99,6 @@ jobs:
- name: cargo clippy
id: clippy
- continue-on-error: true
run: cargo clippy --target ${{ matrix.target }} --all-features --tests -- -D warnings
# Running `cargo build` from the workspace root builds the workspace using
@@ -98,12 +110,12 @@ jobs:
id: build
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
continue-on-error: true
- run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build'
+ run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build --profile ${{ matrix.profile }}'
- name: cargo test
id: test
continue-on-error: true
- run: cargo test --all-features --target ${{ matrix.target }}
+ run: cargo test --all-features --target ${{ matrix.target }} --profile ${{ matrix.profile }}
env:
RUST_BACKTRACE: 1
diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml
index 8bf7361da9..d3e313b3cc 100644
--- a/.github/workflows/rust-release.yml
+++ b/.github/workflows/rust-release.yml
@@ -87,7 +87,7 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
- key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
+ key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
name: Install musl build tools
diff --git a/AGENTS.md b/AGENTS.md
index 5c3f659c35..af25482795 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@
In the codex-rs folder where the rust code lives:
+- Crate names are prefixed with `codex-`. For examole, the `core` folder's crate is named `codex-core`
+- When using format! and you can inline variables into {}, always do that.
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
diff --git a/README.md b/README.md
index 89063b7e7b..0c01654d66 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,9 @@
- [Quickstart](#quickstart)
- [Installing and running Codex CLI](#installing-and-running-codex-cli)
- [Using Codex with your ChatGPT plan](#using-codex-with-your-chatgpt-plan)
- - [Connecting through VPS or remote](#connecting-through-vps-or-remote)
+ - [Connecting on a "Headless" Machine](#connecting-on-a-headless-machine)
+ - [Authenticate locally and copy your credentials to the "headless" machine](#authenticate-locally-and-copy-your-credentials-to-the-headless-machine)
+ - [Connecting through VPS or remote](#connecting-through-vps-or-remote)
- [Usage-based billing alternative: Use an OpenAI API key](#usage-based-billing-alternative-use-an-openai-api-key)
- [Choosing Codex's level of autonomy](#choosing-codexs-level-of-autonomy)
- [**1. Read/write**](#1-readwrite)
@@ -99,17 +101,47 @@ Each archive contains a single entry with the platform baked into the name (e.g.
-After you run `codex` select Sign in with ChatGPT. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
+Run `codex` and select **Sign in with ChatGPT**. You'll need a Plus, Pro, or Team ChatGPT account, and will get access to our latest models, including `gpt-5`, at no extra cost to your plan. (Enterprise is coming soon.)
-> Important: If you've used the Codex CLI before, you'll need to follow these steps to migrate from usage-based billing with your API key:
+> Important: If you've used the Codex CLI before, follow these steps to migrate from usage-based billing with your API key:
>
-> 1. Update the CLI with `codex update` and ensure `codex --version` is greater than 0.13
-> 2. Ensure that there is no `OPENAI_API_KEY` environment variable set. (Check that `env | grep 'OPENAI_API_KEY'` returns empty)
+> 1. Update the CLI and ensure `codex --version` is `0.20.0` or later
+> 2. Delete `~/.codex/auth.json` (this should be `C:\Users\USERNAME\.codex\auth.json` on Windows)
> 3. Run `codex login` again
If you encounter problems with the login flow, please comment on [this issue](https://github.com/openai/codex/issues/1243).
-### Connecting through VPS or remote
+### Connecting on a "Headless" Machine
+
+Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds:
+
+#### Authenticate locally and copy your credentials to the "headless" machine
+
+The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\.codex`).
+
+Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do:
+
+```shell
+# substitute MY_CONTAINER with the name or id of your Docker container:
+CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME)
+docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex"
+docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json"
+```
+
+whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol):
+
+```shell
+ssh user@remote 'mkdir -p ~/.codex'
+scp ~/.codex/auth.json user@remote:~/.codex/auth.json
+```
+
+or try this one-liner:
+
+```shell
+ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json
+```
+
+#### Connecting through VPS or remote
If you run Codex on a remote machine (VPS/server) without a local browser, the login helper starts a server on `localhost:1455` on the remote host. To complete login in your local browser, forward that port to your machine before starting the login flow:
diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js
index d92d8f2f4f..b22c5180c0 100755
--- a/codex-cli/bin/codex.js
+++ b/codex-cli/bin/codex.js
@@ -43,7 +43,7 @@ switch (platform) {
targetTriple = "x86_64-pc-windows-msvc.exe";
break;
case "arm64":
- // We do not build this today, fall through...
+ // We do not build this today, fall through...
default:
break;
}
@@ -65,9 +65,43 @@ 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 || "";
+ const updatedPath = [
+ ...newDirs,
+ ...existingPath.split(pathSep).filter(Boolean),
+ ].join(pathSep);
+ return updatedPath;
+}
+
+const additionalDirs = [];
+const rgDir = await resolveRgDir();
+if (rgDir) {
+ additionalDirs.push(rgDir);
+}
+const updatedPath = getUpdatedPath(additionalDirs);
+
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
- env: { ...process.env, CODEX_MANAGED_BY_NPM: "1" },
+ env: { ...process.env, PATH: updatedPath, CODEX_MANAGED_BY_NPM: "1" },
});
child.on("error", (err) => {
@@ -120,4 +154,3 @@ if (childResult.type === "signal") {
} else {
process.exit(childResult.exitCode);
}
-
diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json
new file mode 100644
index 0000000000..a1c840ade0
--- /dev/null
+++ b/codex-cli/package-lock.json
@@ -0,0 +1,119 @@
+{
+ "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"
+ }
+ }
+ }
+}
diff --git a/codex-cli/package.json b/codex-cli/package.json
index c5464beae5..614ca1a832 100644
--- a/codex-cli/package.json
+++ b/codex-cli/package.json
@@ -16,5 +16,11 @@
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git"
+ },
+ "dependencies": {
+ "@vscode/ripgrep": "^1.15.14"
+ },
+ "devDependencies": {
+ "prettier": "^3.3.3"
}
}
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index e9bc401f0c..7c185b2cc0 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -538,9 +538,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -548,9 +548,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
dependencies = [
"anstream",
"anstyle",
@@ -561,9 +561,9 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.55"
+version = "4.5.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5abde44486daf70c5be8b8f8f1b66c49f86236edf6fa2abadb4d961c4c6229a"
+checksum = "67e4efcbb5da11a92e8a609233aa1e8a7d91e38de0be865f016d14700d45a7fd"
dependencies = [
"clap",
]
@@ -679,7 +679,7 @@ dependencies = [
"clap",
"codex-core",
"serde",
- "toml 0.9.4",
+ "toml 0.9.5",
]
[[package]]
@@ -707,9 +707,11 @@ dependencies = [
"mcp-types",
"mime_guess",
"openssl-sys",
+ "os_info",
"predicates",
"pretty_assertions",
"rand 0.9.2",
+ "regex-lite",
"reqwest",
"seccompiler",
"serde",
@@ -725,7 +727,7 @@ dependencies = [
"tokio",
"tokio-test",
"tokio-util",
- "toml 0.9.4",
+ "toml 0.9.5",
"toml_edit 0.23.3",
"tracing",
"tree-sitter",
@@ -865,7 +867,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-test",
- "toml 0.9.4",
+ "toml 0.9.5",
"tracing",
"tracing-subscriber",
"uuid",
@@ -884,7 +886,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
- "toml 0.9.4",
+ "toml 0.9.5",
"tracing",
"wiremock",
]
@@ -910,7 +912,9 @@ dependencies = [
"image",
"insta",
"lazy_static",
+ "libc",
"mcp-types",
+ "once_cell",
"path-clean",
"pretty_assertions",
"rand 0.8.5",
@@ -3154,6 +3158,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "os_info"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
+dependencies = [
+ "log",
+ "plist",
+ "serde",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "overload"
version = "0.1.1"
@@ -4918,9 +4934,9 @@ dependencies = [
[[package]]
name = "tokio-util"
-version = "0.7.15"
+version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
+checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",
@@ -4943,9 +4959,9 @@ dependencies = [
[[package]]
name = "toml"
-version = "0.9.4"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1"
+checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap 2.10.0",
"serde",
diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs
index 61b1b68f9e..262d219d6d 100644
--- a/codex-rs/apply-patch/src/lib.rs
+++ b/codex-rs/apply-patch/src/lib.rs
@@ -82,8 +82,9 @@ pub struct ApplyPatchArgs {
}
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
+ const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
match argv {
- [cmd, body] if cmd == "apply_patch" => match parse_patch(body) {
+ [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
@@ -722,6 +723,31 @@ mod tests {
}
}
+ #[test]
+ fn test_literal_applypatch() {
+ let args = strs_to_strings(&[
+ "applypatch",
+ r#"*** Begin Patch
+*** Add File: foo
++hi
+*** End Patch
+"#,
+ ]);
+
+ match maybe_parse_apply_patch(&args) {
+ MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
+ assert_eq!(
+ hunks,
+ vec![Hunk::AddFile {
+ path: PathBuf::from("foo"),
+ contents: "hi\n".to_string()
+ }]
+ );
+ }
+ result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
+ }
+ }
+
#[test]
fn test_heredoc() {
let args = strs_to_strings(&[
diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs
index 907783bb81..db75632191 100644
--- a/codex-rs/chatgpt/src/chatgpt_client.rs
+++ b/codex-rs/chatgpt/src/chatgpt_client.rs
@@ -1,4 +1,5 @@
use codex_core::config::Config;
+use codex_core::user_agent::get_codex_user_agent;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -30,7 +31,7 @@ pub(crate) async fn chatgpt_get_request(
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
- .header("User-Agent", "codex-cli")
+ .header("User-Agent", get_codex_user_agent(None))
.send()
.await
.context("Failed to send request")?;
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
index 9aef22c09f..8f59d2d401 100644
--- a/codex-rs/cli/src/main.rs
+++ b/codex-rs/cli/src/main.rs
@@ -27,7 +27,11 @@ use crate::proto::ProtoCli;
author,
version,
// If a sub‑command is given, ignore requirements of the default args.
- subcommand_negates_reqs = true
+ subcommand_negates_reqs = true,
+ // The executable is sometimes invoked via a platform‑specific name like
+ // `codex-x86_64-unknown-linux-musl`, but the help output should always use
+ // the generic `codex` command name that users run.
+ bin_name = "codex"
)]
struct MultitoolCli {
#[clap(flatten)]
diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs
index 6c1de7eaa9..3bc4d81618 100644
--- a/codex-rs/cli/src/proto.rs
+++ b/codex-rs/cli/src/proto.rs
@@ -1,15 +1,14 @@
use std::io::IsTerminal;
-use std::sync::Arc;
use clap::Parser;
use codex_common::CliConfigOverrides;
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
+use codex_core::ConversationManager;
+use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
+use codex_core::protocol::Event;
+use codex_core::protocol::EventMsg;
use codex_core::protocol::Submission;
-use codex_core::util::notify_on_sigint;
-use codex_login::CodexAuth;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -36,22 +35,38 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
- let auth = CodexAuth::from_codex_home(&config.codex_home)?;
- let ctrl_c = notify_on_sigint();
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
- let codex = Arc::new(codex);
+ // Use conversation_manager API to start a conversation
+ let conversation_manager = ConversationManager::default();
+ let NewConversation {
+ conversation_id: _,
+ conversation,
+ session_configured,
+ } = conversation_manager.new_conversation(config).await?;
+
+ // Simulate streaming the session_configured event.
+ let synthetic_event = Event {
+ // Fake id value.
+ id: "".to_string(),
+ msg: EventMsg::SessionConfigured(session_configured),
+ };
+ let session_configured_event = match serde_json::to_string(&synthetic_event) {
+ Ok(s) => s,
+ Err(e) => {
+ error!("Failed to serialize session_configured: {e}");
+ return Err(anyhow::Error::from(e));
+ }
+ };
+ println!("{session_configured_event}");
// Task that reads JSON lines from stdin and forwards to Submission Queue
let sq_fut = {
- let codex = codex.clone();
- let ctrl_c = ctrl_c.clone();
+ let conversation = conversation.clone();
async move {
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
loop {
let result = tokio::select! {
- _ = ctrl_c.notified() => {
- info!("Interrupted, exiting");
+ _ = tokio::signal::ctrl_c() => {
break
},
res = lines.next_line() => res,
@@ -65,7 +80,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
}
match serde_json::from_str::(line) {
Ok(sub) => {
- if let Err(e) = codex.submit_with_id(sub).await {
+ if let Err(e) = conversation.submit_with_id(sub).await {
error!("{e:#}");
break;
}
@@ -88,8 +103,8 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let eq_fut = async move {
loop {
let event = tokio::select! {
- _ = ctrl_c.notified() => break,
- event = codex.next_event() => event,
+ _ = tokio::signal::ctrl_c() => break,
+ event = conversation.next_event() => event,
};
match event {
Ok(event) => {
diff --git a/codex-rs/config.md b/codex-rs/config.md
index 848e7c0444..0d5df17cc8 100644
--- a/codex-rs/config.md
+++ b/codex-rs/config.md
@@ -17,7 +17,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 "codex-mini-latest"
+model = "o3" # overrides the default of "gpt-5"
```
## model_providers
@@ -213,7 +213,7 @@ 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 `codex-mini-latest`)
+4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `gpt-5`)
## model_reasoning_effort
diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml
index 006a218abf..1ea1422bd4 100644
--- a/codex-rs/core/Cargo.toml
+++ b/codex-rs/core/Cargo.toml
@@ -27,11 +27,12 @@ futures = "0.3"
libc = "0.2.174"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
+os_info = "3.12.0"
rand = "0.9"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
-serde_json = "1"
serde_bytes = "0.11"
+serde_json = "1"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
@@ -46,8 +47,8 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
-tokio-util = "0.7.14"
-toml = "0.9.4"
+tokio-util = "0.7.16"
+toml = "0.9.5"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
@@ -75,6 +76,7 @@ core_test_support = { path = "tests/common" }
maplit = "1.0.2"
predicates = "3"
pretty_assertions = "1.4.1"
+regex-lite = "0.1.6"
tempfile = "3"
tokio-test = "0.4"
walkdir = "2.5.0"
diff --git a/codex-rs/core/prompt.md b/codex-rs/core/prompt.md
index 4711dd749a..ff5c2acde6 100644
--- a/codex-rs/core/prompt.md
+++ b/codex-rs/core/prompt.md
@@ -1,6 +1,7 @@
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
Your capabilities:
+
- Receive user prompts and other context provided by the harness, such as files in the workspace.
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
@@ -20,11 +21,13 @@ Your default personality and tone is concise, direct, and friendly. You communic
Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:
- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
-- **Keep it concise**: be no more than 1-2 sentences (8–12 words for quick updates).
+- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
+- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
**Examples:**
+
- “I’ve explored the repo; now checking the API route definitions.”
- “Next, I’ll patch the config and update the related tests.”
- “I’m about to scaffold the CLI commands and helper functions.”
@@ -34,15 +37,18 @@ Before making tool calls, send a brief preamble to the user explaining what you
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
- “Spotted a clever caching util; now hunting where it gets used.”
-**Avoiding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
-- Jumping straight into tool calls without explaining what’s about to happen.
-- Writing overly long or speculative preambles — focus on immediate, tangible next steps.
-
## Planning
-You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. Note that plans are not for padding out simple work with filler steps or stating the obvious. Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
+You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.
+
+Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
+
+Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
+
+Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
Use a plan when:
+
- The task is non-trivial and will require multiple actions over a long time horizon.
- There are logical phases or dependencies where sequencing matters.
- The work has ambiguity that benefits from outlining high-level goals.
@@ -51,14 +57,6 @@ Use a plan when:
- The user has asked you to use the plan tool (aka "TODOs")
- You generate additional steps while working, and plan to do them before yielding to the user
-Skip a plan when:
-- The task is simple and direct.
-- Breaking it down would only produce literal or trivial steps.
-
-Planning steps are called "steps" in the tool, but really they're more like tasks or TODOs. As such they should be very concise descriptions of non-obvious work that an engineer might do like "Write the API spec", then "Update the backend", then "Implement the frontend". On the other hand, it's obvious that you'll usually have to "Explore the codebase" or "Implement the changes", so those are not worth tracking in your plan.
-
-It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
-
### Examples
**High-quality plans**
@@ -115,10 +113,11 @@ If you need to write a plan, only write high quality plans, not low quality ones
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
You MUST adhere to the following criteria when solving queries:
+
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
- Analyzing code for vulnerabilities is allowed.
- Showing user code and tool call details is allowed.
-- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
+- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
@@ -148,21 +147,25 @@ For all of testing, running, building, and formatting, do not attempt to fix unr
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
Filesystem sandboxing prevents you from editing files without user approval. The options are:
-- *read-only*: You can only read files.
-- *workspace-write*: You can read files. You can write to files in your workspace folder, but not outside it.
-- *danger-full-access*: No filesystem sandboxing.
+
+- **read-only**: You can only read files.
+- **workspace-write**: You can read files. You can write to files in your workspace folder, but not outside it.
+- **danger-full-access**: No filesystem sandboxing.
Network sandboxing prevents you from accessing network without approval. Options are
-- *ON*
-- *OFF*
+
+- **restricted**
+- **enabled**
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task. Approval options are
-- *untrusted*: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
-- *on-failure*: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
-- *on-request*: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
-- *never*: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
+
+- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
+- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
+- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
+- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is pared with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
+
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
@@ -207,6 +210,7 @@ Brevity is very important as a default. You should be very concise (i.e. no more
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
**Section Headers**
+
- Use only when they improve clarity — they are not mandatory for every answer.
- Choose descriptive names that fit the content
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
@@ -214,6 +218,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
**Bullets**
+
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
@@ -222,11 +227,13 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use consistent keyword phrasing and formatting across sections.
**Monospace**
+
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).
**Structure**
+
- Place related bullets together; don’t mix unrelated concepts in the same section.
- Order sections from general → specific → supporting info.
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
@@ -235,6 +242,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Simple results → minimal headers, possibly just a short list or paragraph.
**Tone**
+
- Keep the voice collaborative and natural, like a coding partner handing off work.
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
@@ -242,6 +250,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Use parallel structure in lists for consistency.
**Don’t**
+
- Don’t use literal words “bold” or “monospace” in the content.
- Don’t nest bullets or create deep hierarchies.
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
@@ -252,7 +261,14 @@ Generally, ensure your final answers adapt their shape and depth to the request.
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
-# Tools
+# Tool Guidelines
+
+## Shell commands
+
+When using the shell, you must adhere to the following guidelines:
+
+- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
+- Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
## `apply_patch`
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index dae140bc02..840e808fc7 100644
--- a/codex-rs/core/src/chat_completions.rs
+++ b/codex-rs/core/src/chat_completions.rs
@@ -588,6 +588,9 @@ where
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
continue;
}
+ Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => {
+ continue;
+ }
}
}
}
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 0caf1170a6..f0229d45ae 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -38,6 +38,7 @@ use crate::model_provider_info::WireApi;
use crate::models::ResponseItem;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::TokenUsage;
+use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use std::sync::Arc;
@@ -169,6 +170,7 @@ impl ModelClient {
store,
stream: true,
include,
+ prompt_cache_key: Some(self.session_id.to_string()),
};
let mut attempt = 0;
@@ -207,6 +209,7 @@ impl ModelClient {
.as_deref()
.unwrap_or("codex_cli_rs");
req_builder = req_builder.header("originator", originator);
+ req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
let res = req_builder.send().await;
if let Ok(resp) = &res {
@@ -403,6 +406,8 @@ async fn process_sse(
}
};
+ trace!("SSE event: {}", sse.data);
+
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
@@ -411,7 +416,6 @@ async fn process_sse(
}
};
- trace!(?event, "SSE event");
match event.kind.as_str() {
// Individual output item finalised. Forward immediately so the
// rest of the agent can stream assistant text/functions *live*
@@ -503,12 +507,18 @@ async fn process_sse(
| "response.function_call_arguments.delta"
| "response.in_progress"
| "response.output_item.added"
- | "response.output_text.done"
- | "response.reasoning_summary_part.added"
- | "response.reasoning_summary_text.done" => {
- // Currently, we ignore these events, but we handle them
+ | "response.output_text.done" => {
+ // Currently, we ignore this event, but we handle it
// separately to skip the logging message in the `other` case.
}
+ "response.reasoning_summary_part.added" => {
+ // Boundary between reasoning summary sections (e.g., titles).
+ let event = ResponseEvent::ReasoningSummaryPartAdded;
+ if tx_event.send(Ok(event)).await.is_err() {
+ return;
+ }
+ }
+ "response.reasoning_summary_text.done" => {}
other => debug!(other, "sse event"),
}
}
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index b37b1e3f80..440d250b62 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -144,6 +144,7 @@ pub enum ResponseEvent {
OutputTextDelta(String),
ReasoningSummaryDelta(String),
ReasoningContentDelta(String),
+ ReasoningSummaryPartAdded,
}
#[derive(Debug, Serialize)]
@@ -215,6 +216,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) prompt_cache_key: Option,
}
pub(crate) fn create_reasoning_param_for_request(
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 3bf6288fdf..5ac1392bee 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -20,7 +20,6 @@ use futures::prelude::*;
use mcp_types::CallToolResult;
use serde::Serialize;
use serde_json;
-use tokio::sync::Notify;
use tokio::sync::oneshot;
use tokio::task::AbortHandle;
use tracing::debug;
@@ -51,6 +50,7 @@ use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
+use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::exec_env::create_env;
use crate::mcp_connection_manager::McpConnectionManager;
@@ -65,6 +65,7 @@ use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
+use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
@@ -73,6 +74,7 @@ use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentDeltaEvent;
use crate::protocol::AgentReasoningRawContentEvent;
+use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
use crate::protocol::BackgroundEventEvent;
@@ -121,11 +123,7 @@ pub struct CodexSpawnOk {
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
- pub async fn spawn(
- config: Config,
- auth: Option,
- ctrl_c: Arc,
- ) -> CodexResult {
+ pub async fn spawn(config: Config, auth: Option) -> CodexResult {
// experimental resume path (undocumented)
let resume_path = config.experimental_resume.clone();
info!("resume_path: {resume_path:?}");
@@ -153,9 +151,9 @@ impl Codex {
// Generate a unique ID for the lifetime of this Codex session.
let session_id = Uuid::new_v4();
- tokio::spawn(submission_loop(
- session_id, config, auth, rx_sub, tx_event, ctrl_c,
- ));
+
+ // This task will run until Op::Shutdown is received.
+ tokio::spawn(submission_loop(session_id, config, auth, rx_sub, tx_event));
let codex = Codex {
next_id: AtomicU64::new(0),
tx_sub,
@@ -207,7 +205,6 @@ impl Codex {
pub(crate) struct Session {
client: ModelClient,
pub(crate) tx_event: Sender,
- ctrl_c: Arc,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
@@ -402,6 +399,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
+ parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
@@ -429,8 +427,8 @@ impl Session {
// Because stdout and stderr could each be up to 100 KiB, we send
// truncated versions.
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
- let stdout = stdout.chars().take(MAX_STREAM_OUTPUT).collect();
- let stderr = stderr.chars().take(MAX_STREAM_OUTPUT).collect();
+ let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
+ let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -489,7 +487,6 @@ impl Session {
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
- exec_args.ctrl_c,
exec_args.sandbox_policy,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
@@ -502,8 +499,8 @@ impl Session {
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
- stdout: String::new(),
- stderr: get_error_message_ui(e),
+ stdout: StreamOutput::new(String::new()),
+ stderr: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
};
&output_stderr
@@ -574,7 +571,7 @@ impl Session {
.await
}
- pub fn abort(&self) {
+ fn abort(&self) {
info!("Aborting existing session");
let mut state = self.state.lock().unwrap();
state.pending_approvals.clear();
@@ -705,7 +702,6 @@ async fn submission_loop(
auth: Option,
rx_sub: Receiver,
tx_event: Sender,
- ctrl_c: Arc,
) {
let mut sess: Option> = None;
// shorthand - send an event when there is no active session
@@ -720,21 +716,8 @@ async fn submission_loop(
tx_event.send(event).await.ok();
};
- loop {
- let interrupted = ctrl_c.notified();
- let sub = tokio::select! {
- res = rx_sub.recv() => match res {
- Ok(sub) => sub,
- Err(_) => break,
- },
- _ = interrupted => {
- if let Some(sess) = sess.as_ref(){
- sess.abort();
- }
- continue;
- },
- };
-
+ // To break out of this loop, send Op::Shutdown.
+ while let Ok(sub) = rx_sub.recv().await {
debug!(?sub, "Submission");
match sub.op {
Op::Interrupt => {
@@ -873,7 +856,6 @@ async fn submission_loop(
config.include_plan_tool,
),
tx_event: tx_event.clone(),
- ctrl_c: Arc::clone(&ctrl_c),
user_instructions,
base_instructions,
approval_policy,
@@ -1474,6 +1456,13 @@ async fn try_run_turn(
};
sess.tx_event.send(event).await.ok();
}
+ ResponseEvent::ReasoningSummaryPartAdded => {
+ let event = Event {
+ id: sub_id.to_string(),
+ msg: EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}),
+ };
+ sess.tx_event.send(event).await.ok();
+ }
ResponseEvent::ReasoningContentDelta(delta) => {
if sess.show_raw_agent_reasoning {
let event = Event {
@@ -1776,7 +1765,6 @@ fn parse_container_exec_arguments(
pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
- pub ctrl_c: Arc,
pub sandbox_policy: &'a SandboxPolicy,
pub codex_linux_sandbox_exe: &'a Option,
pub stdout_stream: Option,
@@ -1961,7 +1949,6 @@ async fn handle_container_exec_with_params(
ExecInvokeArgs {
params: params.clone(),
sandbox_type,
- ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
@@ -1975,19 +1962,10 @@ async fn handle_container_exec_with_params(
match output_result {
Ok(output) => {
- let ExecToolCallOutput {
- exit_code,
- stdout,
- stderr,
- duration,
- } = &output;
+ let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0;
- let content = format_exec_output(
- if is_success { stdout } else { stderr },
- *exit_code,
- *duration,
- );
+ let content = format_exec_output(output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
@@ -2102,7 +2080,6 @@ async fn handle_sandbox_error(
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
- ctrl_c: sess.ctrl_c.clone(),
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
@@ -2116,19 +2093,10 @@ async fn handle_sandbox_error(
match retry_output_result {
Ok(retry_output) => {
- let ExecToolCallOutput {
- exit_code,
- stdout,
- stderr,
- duration,
- } = &retry_output;
+ let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
- let content = format_exec_output(
- if is_success { stdout } else { stderr },
- *exit_code,
- *duration,
- );
+ let content = format_exec_output(retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -2161,7 +2129,14 @@ async fn handle_sandbox_error(
}
/// Exec output is a pre-serialized JSON payload
-fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
+fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
+ let ExecToolCallOutput {
+ exit_code,
+ stdout,
+ stderr,
+ duration,
+ } = exec_output;
+
#[derive(Serialize)]
struct ExecMetadata {
exit_code: i32,
@@ -2177,8 +2152,18 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
+ let is_success = exit_code == 0;
+ let output = if is_success { stdout } else { stderr };
+
+ let mut formatted_output = output.text;
+ if let Some(truncated_after_lines) = output.truncated_after_lines {
+ formatted_output.push_str(&format!(
+ "\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
+ ));
+ }
+
let payload = ExecOutput {
- output,
+ output: &formatted_output,
metadata: ExecMetadata {
exit_code,
duration_seconds,
diff --git a/codex-rs/core/src/codex_conversation.rs b/codex-rs/core/src/codex_conversation.rs
new file mode 100644
index 0000000000..d3b00046fc
--- /dev/null
+++ b/codex-rs/core/src/codex_conversation.rs
@@ -0,0 +1,30 @@
+use crate::codex::Codex;
+use crate::error::Result as CodexResult;
+use crate::protocol::Event;
+use crate::protocol::Op;
+use crate::protocol::Submission;
+
+pub struct CodexConversation {
+ codex: Codex,
+}
+
+/// Conduit for the bidirectional stream of messages that compose a conversation
+/// in Codex.
+impl CodexConversation {
+ pub(crate) fn new(codex: Codex) -> Self {
+ Self { codex }
+ }
+
+ pub async fn submit(&self, op: Op) -> CodexResult {
+ self.codex.submit(op).await
+ }
+
+ /// Use sparingly: this is intended to be removed soon.
+ pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
+ self.codex.submit_with_id(sub).await
+ }
+
+ pub async fn next_event(&self) -> CodexResult {
+ self.codex.next_event().await
+ }
+}
diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs
deleted file mode 100644
index dc10ec8d84..0000000000
--- a/codex-rs/core/src/codex_wrapper.rs
+++ /dev/null
@@ -1,59 +0,0 @@
-use std::sync::Arc;
-
-use crate::Codex;
-use crate::CodexSpawnOk;
-use crate::config::Config;
-use crate::protocol::Event;
-use crate::protocol::EventMsg;
-use crate::util::notify_on_sigint;
-use codex_login::CodexAuth;
-use tokio::sync::Notify;
-use uuid::Uuid;
-
-/// Represents an active Codex conversation, including the first event
-/// (which is [`EventMsg::SessionConfigured`]).
-pub struct CodexConversation {
- pub codex: Codex,
- pub session_id: Uuid,
- pub session_configured: Event,
- pub ctrl_c: Arc,
-}
-
-/// Spawn a new [`Codex`] and initialize the session.
-///
-/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
-/// is received as a response to the initial `ConfigureSession` submission so
-/// that callers can surface the information to the UI.
-pub async fn init_codex(config: Config) -> anyhow::Result {
- let ctrl_c = notify_on_sigint();
- let auth = CodexAuth::from_codex_home(&config.codex_home)?;
- let CodexSpawnOk {
- codex,
- init_id,
- session_id,
- } = Codex::spawn(config, auth, ctrl_c.clone()).await?;
-
- // The first event must be `SessionInitialized`. Validate and forward it to
- // the caller so that they can display it in the conversation history.
- let event = codex.next_event().await?;
- if event.id != init_id
- || !matches!(
- &event,
- Event {
- id: _id,
- msg: EventMsg::SessionConfigured(_),
- }
- )
- {
- return Err(anyhow::anyhow!(
- "expected SessionInitialized but got {event:?}"
- ));
- }
-
- Ok(CodexConversation {
- codex,
- session_id,
- session_configured: event,
- ctrl_c,
- })
-}
diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs
new file mode 100644
index 0000000000..4a6cdc1bf1
--- /dev/null
+++ b/codex-rs/core/src/conversation_manager.rs
@@ -0,0 +1,96 @@
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use codex_login::CodexAuth;
+use tokio::sync::RwLock;
+use uuid::Uuid;
+
+use crate::codex::Codex;
+use crate::codex::CodexSpawnOk;
+use crate::codex_conversation::CodexConversation;
+use crate::config::Config;
+use crate::error::CodexErr;
+use crate::error::Result as CodexResult;
+use crate::protocol::Event;
+use crate::protocol::EventMsg;
+use crate::protocol::SessionConfiguredEvent;
+
+/// Represents a newly created Codex conversation, including the first event
+/// (which is [`EventMsg::SessionConfigured`]).
+pub struct NewConversation {
+ pub conversation_id: Uuid,
+ pub conversation: Arc,
+ pub session_configured: SessionConfiguredEvent,
+}
+
+/// [`ConversationManager`] is responsible for creating conversations and
+/// maintaining them in memory.
+pub struct ConversationManager {
+ conversations: Arc>>>,
+}
+
+impl Default for ConversationManager {
+ fn default() -> Self {
+ Self {
+ conversations: Arc::new(RwLock::new(HashMap::new())),
+ }
+ }
+}
+
+impl ConversationManager {
+ pub async fn new_conversation(&self, config: Config) -> CodexResult {
+ let auth = CodexAuth::from_codex_home(&config.codex_home)?;
+ self.new_conversation_with_auth(config, auth).await
+ }
+
+ /// Used for integration tests: should not be used by ordinary business
+ /// logic.
+ pub async fn new_conversation_with_auth(
+ &self,
+ config: Config,
+ auth: Option,
+ ) -> CodexResult {
+ let CodexSpawnOk {
+ codex,
+ init_id,
+ session_id: conversation_id,
+ } = Codex::spawn(config, auth).await?;
+
+ // The first event must be `SessionInitialized`. Validate and forward it
+ // to the caller so that they can display it in the conversation
+ // history.
+ let event = codex.next_event().await?;
+ let session_configured = match event {
+ Event {
+ id,
+ msg: EventMsg::SessionConfigured(session_configured),
+ } if id == init_id => session_configured,
+ _ => {
+ return Err(CodexErr::SessionConfiguredNotFirstEvent);
+ }
+ };
+
+ let conversation = Arc::new(CodexConversation::new(codex));
+ self.conversations
+ .write()
+ .await
+ .insert(conversation_id, conversation.clone());
+
+ Ok(NewConversation {
+ conversation_id,
+ conversation,
+ session_configured,
+ })
+ }
+
+ pub async fn get_conversation(
+ &self,
+ conversation_id: Uuid,
+ ) -> CodexResult> {
+ let conversations = self.conversations.read().await;
+ conversations
+ .get(&conversation_id)
+ .cloned()
+ .ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
+ }
+}
diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs
index 2931d30636..4df6663394 100644
--- a/codex-rs/core/src/error.rs
+++ b/codex-rs/core/src/error.rs
@@ -3,6 +3,7 @@ use serde_json;
use std::io;
use thiserror::Error;
use tokio::task::JoinError;
+use uuid::Uuid;
pub type Result = std::result::Result;
@@ -44,6 +45,12 @@ pub enum CodexErr {
#[error("stream disconnected before completion: {0}")]
Stream(String),
+ #[error("no conversation with id: {0}")]
+ ConversationNotFound(Uuid),
+
+ #[error("session configured event was not the first event in the stream")]
+ SessionConfiguredNotFirstEvent,
+
/// Returned by run_command_stream when the spawned child process timed out (10s).
#[error("timeout waiting for child process to exit")]
Timeout,
diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs
index 10606b6821..50bba1b4b7 100644
--- a/codex-rs/core/src/exec.rs
+++ b/codex-rs/core/src/exec.rs
@@ -6,7 +6,6 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
-use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -15,7 +14,6 @@ use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
-use tokio::sync::Notify;
use crate::error::CodexErr;
use crate::error::Result;
@@ -80,7 +78,6 @@ pub struct StdoutStream {
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_type: SandboxType,
- ctrl_c: Arc,
sandbox_policy: &SandboxPolicy,
codex_linux_sandbox_exe: &Option,
stdout_stream: Option,
@@ -89,7 +86,7 @@ pub async fn process_exec_tool_call(
let raw_output_result: std::result::Result = match sandbox_type
{
- SandboxType::None => exec(params, sandbox_policy, ctrl_c, stdout_stream.clone()).await,
+ SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await,
SandboxType::MacosSeatbelt => {
let timeout = params.timeout_duration();
let ExecParams {
@@ -103,7 +100,7 @@ pub async fn process_exec_tool_call(
env,
)
.await?;
- consume_truncated_output(child, ctrl_c, timeout, stdout_stream.clone()).await
+ consume_truncated_output(child, timeout, stdout_stream.clone()).await
}
SandboxType::LinuxSeccomp => {
let timeout = params.timeout_duration();
@@ -124,14 +121,14 @@ pub async fn process_exec_tool_call(
)
.await?;
- consume_truncated_output(child, ctrl_c, timeout, stdout_stream).await
+ consume_truncated_output(child, timeout, stdout_stream).await
}
};
let duration = start.elapsed();
match raw_output_result {
Ok(raw_output) => {
- let stdout = String::from_utf8_lossy(&raw_output.stdout).to_string();
- let stderr = String::from_utf8_lossy(&raw_output.stderr).to_string();
+ let stdout = raw_output.stdout.from_utf8_lossy();
+ let stderr = raw_output.stderr.from_utf8_lossy();
#[cfg(target_family = "unix")]
match raw_output.exit_status.signal() {
@@ -146,7 +143,9 @@ pub async fn process_exec_tool_call(
if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) {
return Err(CodexErr::Sandbox(SandboxErr::Denied(
- exit_code, stdout, stderr,
+ exit_code,
+ stdout.text,
+ stderr.text,
)));
}
@@ -243,25 +242,47 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool {
true
}
+#[derive(Debug)]
+pub struct StreamOutput {
+ pub text: T,
+ pub truncated_after_lines: Option,
+}
#[derive(Debug)]
pub struct RawExecToolCallOutput {
pub exit_status: ExitStatus,
- pub stdout: Vec,
- pub stderr: Vec,
+ pub stdout: StreamOutput>,
+ pub stderr: StreamOutput>,
+}
+
+impl StreamOutput {
+ pub fn new(text: String) -> Self {
+ Self {
+ text,
+ truncated_after_lines: None,
+ }
+ }
+}
+
+impl StreamOutput> {
+ pub fn from_utf8_lossy(&self) -> StreamOutput {
+ StreamOutput {
+ text: String::from_utf8_lossy(&self.text).to_string(),
+ truncated_after_lines: self.truncated_after_lines,
+ }
+ }
}
#[derive(Debug)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
- pub stdout: String,
- pub stderr: String,
+ pub stdout: StreamOutput,
+ pub stderr: StreamOutput,
pub duration: Duration,
}
async fn exec(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
- ctrl_c: Arc,
stdout_stream: Option,
) -> Result {
let timeout = params.timeout_duration();
@@ -286,14 +307,13 @@ async fn exec(
env,
)
.await?;
- consume_truncated_output(child, ctrl_c, timeout, stdout_stream).await
+ consume_truncated_output(child, timeout, stdout_stream).await
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
pub(crate) async fn consume_truncated_output(
mut child: Child,
- ctrl_c: Arc,
timeout: Duration,
stdout_stream: Option,
) -> Result {
@@ -327,7 +347,6 @@ pub(crate) async fn consume_truncated_output(
true,
));
- let interrupted = ctrl_c.notified();
let exit_status = tokio::select! {
result = tokio::time::timeout(timeout, child.wait()) => {
match result {
@@ -341,7 +360,7 @@ pub(crate) async fn consume_truncated_output(
}
}
}
- _ = interrupted => {
+ _ = tokio::signal::ctrl_c() => {
child.start_kill()?;
synthetic_exit_status(128 + SIGKILL_CODE)
}
@@ -363,7 +382,7 @@ async fn read_capped(
max_lines: usize,
stream: Option,
is_stderr: bool,
-) -> io::Result> {
+) -> io::Result>> {
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
let mut tmp = [0u8; 8192];
@@ -413,7 +432,16 @@ async fn read_capped(
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
}
- Ok(buf)
+ let truncated = remaining_lines == 0 || remaining_bytes == 0;
+
+ Ok(StreamOutput {
+ text: buf,
+ truncated_after_lines: if truncated {
+ Some((max_lines - remaining_lines) as u32)
+ } else {
+ None
+ },
+ })
}
#[cfg(unix)]
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index c728bd3125..aeab49702c 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -11,9 +11,8 @@ mod chat_completions;
mod client;
mod client_common;
pub mod codex;
-pub use codex::Codex;
-pub use codex::CodexSpawnOk;
-pub mod codex_wrapper;
+mod codex_conversation;
+pub use codex_conversation::CodexConversation;
pub mod config;
pub mod config_profile;
pub mod config_types;
@@ -28,11 +27,15 @@ mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
+pub mod parse_command;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
pub use model_provider_info::create_oss_provider_with_base_url;
+mod conversation_manager;
+pub use conversation_manager::ConversationManager;
+pub use conversation_manager::NewConversation;
pub mod model_family;
mod models;
mod openai_model_info;
@@ -46,6 +49,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod turn_diff_tracker;
+pub mod user_agent;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
index 1c92c07c12..ad794c38f1 100644
--- a/codex-rs/core/src/openai_tools.rs
+++ b/codex-rs/core/src/openai_tools.rs
@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
+use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -81,6 +82,8 @@ pub(crate) enum JsonSchema {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option,
},
+ /// MCP schema allows "number" | "integer" for Number
+ #[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option,
@@ -296,7 +299,13 @@ pub(crate) fn mcp_tool_to_openai_tool(
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
}
- let serialized_input_schema = serde_json::to_value(input_schema)?;
+ // Serialize to a raw JSON value so we can sanitize schemas coming from MCP
+ // servers. Some servers omit the top-level or nested `type` in JSON
+ // Schemas (e.g. using enum/anyOf), or use unsupported variants like
+ // `integer`. Our internal JsonSchema is a small subset and requires
+ // `type`, so we coerce/sanitize here for compatibility.
+ let mut serialized_input_schema = serde_json::to_value(input_schema)?;
+ sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::(serialized_input_schema)?;
Ok(ResponsesApiTool {
@@ -307,6 +316,120 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
+/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
+/// JsonSchema enum. This function:
+/// - Ensures every schema object has a "type". If missing, infers it from
+/// common keywords (properties => object, items => array, enum/const/format => string)
+/// and otherwise defaults to "string".
+/// - Fills required child fields (e.g. array items, object properties) with
+/// permissive defaults when absent.
+fn sanitize_json_schema(value: &mut JsonValue) {
+ match value {
+ JsonValue::Bool(_) => {
+ // JSON Schema boolean form: true/false. Coerce to an accept-all string.
+ *value = json!({ "type": "string" });
+ }
+ JsonValue::Array(arr) => {
+ for v in arr.iter_mut() {
+ sanitize_json_schema(v);
+ }
+ }
+ JsonValue::Object(map) => {
+ // First, recursively sanitize known nested schema holders
+ if let Some(props) = map.get_mut("properties") {
+ if let Some(props_map) = props.as_object_mut() {
+ for (_k, v) in props_map.iter_mut() {
+ sanitize_json_schema(v);
+ }
+ }
+ }
+ if let Some(items) = map.get_mut("items") {
+ sanitize_json_schema(items);
+ }
+ // Some schemas use oneOf/anyOf/allOf - sanitize their entries
+ for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
+ if let Some(v) = map.get_mut(combiner) {
+ sanitize_json_schema(v);
+ }
+ }
+
+ // Normalize/ensure type
+ let mut ty = map
+ .get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ // If type is an array (union), pick first supported; else leave to inference
+ if ty.is_none() {
+ if let Some(JsonValue::Array(types)) = map.get("type") {
+ for t in types {
+ if let Some(tt) = t.as_str() {
+ if matches!(
+ tt,
+ "object" | "array" | "string" | "number" | "integer" | "boolean"
+ ) {
+ ty = Some(tt.to_string());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Infer type if still missing
+ if ty.is_none() {
+ if map.contains_key("properties")
+ || map.contains_key("required")
+ || map.contains_key("additionalProperties")
+ {
+ ty = Some("object".to_string());
+ } else if map.contains_key("items") || map.contains_key("prefixItems") {
+ ty = Some("array".to_string());
+ } else if map.contains_key("enum")
+ || map.contains_key("const")
+ || map.contains_key("format")
+ {
+ ty = Some("string".to_string());
+ } else if map.contains_key("minimum")
+ || map.contains_key("maximum")
+ || map.contains_key("exclusiveMinimum")
+ || map.contains_key("exclusiveMaximum")
+ || map.contains_key("multipleOf")
+ {
+ ty = Some("number".to_string());
+ }
+ }
+ // If we still couldn't infer, default to string
+ let ty = ty.unwrap_or_else(|| "string".to_string());
+ map.insert("type".to_string(), JsonValue::String(ty.to_string()));
+
+ // Ensure object schemas have properties map
+ if ty == "object" {
+ if !map.contains_key("properties") {
+ map.insert(
+ "properties".to_string(),
+ JsonValue::Object(serde_json::Map::new()),
+ );
+ }
+ // If additionalProperties is an object schema, sanitize it too.
+ // Leave booleans as-is, since JSON Schema allows boolean here.
+ if let Some(ap) = map.get_mut("additionalProperties") {
+ let is_bool = matches!(ap, JsonValue::Bool(_));
+ if !is_bool {
+ sanitize_json_schema(ap);
+ }
+ }
+ }
+
+ // Ensure array schemas have items
+ if ty == "array" && !map.contains_key("items") {
+ map.insert("items".to_string(), json!({ "type": "string" }));
+ }
+ }
+ _ => {}
+ }
+}
+
/// Returns a list of OpenAiTools based on the provided config and MCP tools.
/// Note that the keys of mcp_tools should be fully qualified names. See
/// [`McpConnectionManager`] for more details.
@@ -351,6 +474,7 @@ pub(crate) fn get_openai_tools(
mod tests {
use crate::model_family::find_family_for_model;
use mcp_types::ToolInputSchema;
+ use pretty_assertions::assert_eq;
use super::*;
@@ -497,4 +621,212 @@ mod tests {
})
);
}
+
+ #[test]
+ fn test_mcp_tool_property_missing_type_defaults_to_string() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/search".to_string(),
+ mcp_types::Tool {
+ name: "search".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "query": {
+ "description": "search query"
+ }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Search docs".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/search"]);
+
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/search".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "query".to_string(),
+ JsonSchema::String {
+ description: Some("search query".to_string())
+ }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Search docs".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_integer_normalized_to_number() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/paginate".to_string(),
+ mcp_types::Tool {
+ name: "paginate".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "page": { "type": "integer" }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Pagination".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/paginate"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/paginate".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "page".to_string(),
+ JsonSchema::Number { description: None }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Pagination".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_array_without_items_gets_default_string_items() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/tags".to_string(),
+ mcp_types::Tool {
+ name: "tags".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "tags": { "type": "array" }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("Tags".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/tags"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/tags".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "tags".to_string(),
+ JsonSchema::Array {
+ items: Box::new(JsonSchema::String { description: None }),
+ description: None
+ }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "Tags".to_string(),
+ strict: false,
+ })
+ );
+ }
+
+ #[test]
+ fn test_mcp_tool_anyof_defaults_to_string() {
+ let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
+ let config = ToolsConfig::new(
+ &model_family,
+ AskForApproval::Never,
+ SandboxPolicy::ReadOnly,
+ false,
+ );
+
+ let tools = get_openai_tools(
+ &config,
+ Some(HashMap::from([(
+ "dash/value".to_string(),
+ mcp_types::Tool {
+ name: "value".to_string(),
+ input_schema: ToolInputSchema {
+ properties: Some(serde_json::json!({
+ "value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] }
+ })),
+ required: None,
+ r#type: "object".to_string(),
+ },
+ output_schema: None,
+ title: None,
+ annotations: None,
+ description: Some("AnyOf Value".to_string()),
+ },
+ )])),
+ );
+
+ assert_eq_tool_names(&tools, &["shell", "dash/value"]);
+ assert_eq!(
+ tools[1],
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "dash/value".to_string(),
+ parameters: JsonSchema::Object {
+ properties: BTreeMap::from([(
+ "value".to_string(),
+ JsonSchema::String { description: None }
+ )]),
+ required: None,
+ additional_properties: None,
+ },
+ description: "AnyOf Value".to_string(),
+ strict: false,
+ })
+ );
+ }
}
diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs
new file mode 100644
index 0000000000..01dc6e3227
--- /dev/null
+++ b/codex-rs/core/src/parse_command.rs
@@ -0,0 +1,2045 @@
+use crate::bash::try_parse_bash;
+use crate::bash::try_parse_word_only_commands_sequence;
+use serde::Deserialize;
+use serde::Serialize;
+use shlex::split as shlex_split;
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+pub enum ParsedCommand {
+ Read {
+ cmd: Vec,
+ name: String,
+ },
+ ListFiles {
+ cmd: Vec,
+ path: Option,
+ },
+ Search {
+ cmd: Vec,
+ query: Option,
+ path: Option,
+ },
+ Format {
+ cmd: Vec,
+ tool: Option,
+ targets: Option>,
+ },
+ Test {
+ cmd: Vec,
+ },
+ Lint {
+ cmd: Vec,
+ tool: Option,
+ targets: Option>,
+ },
+ Unknown {
+ cmd: Vec,
+ },
+}
+
+/// DO NOT REVIEW THIS CODE BY HAND
+/// This parsing code is quite complex and not easy to hand-modify.
+/// The easiest way to iterate is to add unit tests and have Codex fix the implementation.
+/// To encourage this, the tests have been put directly below this function rather than at the bottom of the
+///
+/// Parses metadata out of an arbitrary command.
+/// These commands are model driven and could include just about anything.
+/// The parsing is slightly lossy due to the ~infinite expressiveness of an arbitrary command.
+/// The goal of the parsed metadata is to be able to provide the user with a human readable gis
+/// of what it is doing.
+pub fn parse_command(command: &[String]) -> Vec {
+ // Parse and then collapse consecutive duplicate commands to avoid redundant summaries.
+ let parsed = parse_command_impl(command);
+ let mut deduped: Vec = Vec::with_capacity(parsed.len());
+ for cmd in parsed.into_iter() {
+ if deduped.last().is_some_and(|prev| prev == &cmd) {
+ continue;
+ }
+ deduped.push(cmd);
+ }
+ deduped
+}
+
+#[cfg(test)]
+#[allow(clippy::items_after_test_module)]
+/// Tests are at the top to encourage using TDD + Codex to fix the implementation.
+mod tests {
+ use super::*;
+
+ fn shlex_split_safe(s: &str) -> Vec {
+ shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(|s| s.to_string()).collect())
+ }
+
+ fn vec_str(args: &[&str]) -> Vec {
+ args.iter().map(|s| s.to_string()).collect()
+ }
+
+ fn assert_parsed(args: &[String], expected: Vec) {
+ let out = parse_command(args);
+ assert_eq!(out, expected);
+ }
+
+ #[test]
+ fn git_status_is_unknown() {
+ assert_parsed(
+ &vec_str(&["git", "status"]),
+ vec![ParsedCommand::Unknown {
+ cmd: vec_str(&["git", "status"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn handles_complex_bash_command_head() {
+ let inner =
+ "rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ // Expect commands in left-to-right execution order
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--version"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["node", "-v"]),
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["pnpm", "-v"]),
+ },
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "40"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_searching_for_navigate_to_route() -> anyhow::Result<()> {
+ let inner = "rg -n \"navigate-to-route\" -S";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe(inner),
+ query: Some("navigate-to-route".to_string()),
+ path: None,
+ }],
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn handles_complex_bash_command() {
+ let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "-n", "BUG|FIXME|TODO|XXX|HACK", "-S"]),
+ query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "200"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_rg_files_with_path_and_pipe() {
+ let inner = "rg --files webview/src | sed -n";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files", "webview/src"]),
+ query: None,
+ path: Some("webview".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_rg_files_then_head() {
+ let inner = "rg --files | head -n 50";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["head", "-n", "50"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn supports_cat() {
+ let inner = "cat webview/README.md";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_ls_with_pipe() {
+ let inner = "ls -la | sed -n '1,120p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::ListFiles {
+ cmd: vec_str(&["ls", "-la"]),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_head_n() {
+ let inner = "head -n 50 Cargo.toml";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cat_sed_n() {
+ let inner = "cat tui/Cargo.toml | sed -n '1,200p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_tail_n_plus() {
+ let inner = "tail -n +522 README.md";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_tail_n_last_lines() {
+ let inner = "tail -n 30 README.md";
+ let out = parse_command(&vec_str(&["bash", "-lc", inner]));
+ assert_eq!(
+ out,
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "README.md".to_string(),
+ }]
+ );
+ }
+
+ #[test]
+ fn supports_npm_run_build_is_unknown() {
+ assert_parsed(
+ &vec_str(&["npm", "run", "build"]),
+ vec![ParsedCommand::Unknown {
+ cmd: vec_str(&["npm", "run", "build"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_npm_run_with_forwarded_args() {
+ assert_parsed(
+ &vec_str(&[
+ "npm",
+ "run",
+ "lint",
+ "--",
+ "--max-warnings",
+ "0",
+ "--format",
+ "json",
+ ]),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&[
+ "npm",
+ "run",
+ "lint",
+ "--",
+ "--max-warnings",
+ "0",
+ "--format",
+ "json",
+ ]),
+ tool: Some("npm-script:lint".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_recursive_current_dir() {
+ assert_parsed(
+ &vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
+ query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_recursive_specific_file() {
+ assert_parsed(
+ &vec_str(&[
+ "grep",
+ "-R",
+ "CODEX_SANDBOX_ENV_VAR",
+ "-n",
+ "core/src/spawn.rs",
+ ]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&[
+ "grep",
+ "-R",
+ "CODEX_SANDBOX_ENV_VAR",
+ "-n",
+ "core/src/spawn.rs",
+ ]),
+ query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
+ path: Some("spawn.rs".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_query_with_slashes_not_shortened() {
+ // Query strings may contain slashes and should not be shortened to the basename.
+ // Previously, grep queries were passed through short_display_path, which is incorrect.
+ assert_parsed(
+ &shlex_split_safe("grep -R src/main.rs -n ."),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "src/main.rs", "-n", "."]),
+ query: Some("src/main.rs".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_grep_weird_backtick_in_query() {
+ assert_parsed(
+ &shlex_split_safe("grep -R COD`EX_SANDBOX -n"),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["grep", "-R", "COD`EX_SANDBOX", "-n"]),
+ query: Some("COD`EX_SANDBOX".to_string()),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cd_and_rg_files() {
+ assert_parsed(
+ &shlex_split_safe("cd codex-rs && rg --files"),
+ vec![
+ ParsedCommand::Unknown {
+ cmd: vec_str(&["cd", "codex-rs"]),
+ },
+ ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn echo_then_cargo_test_sequence() {
+ assert_parsed(
+ &shlex_split_safe("echo Running tests... && cargo test --all-features --quiet"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["cargo", "test", "--all-features", "--quiet"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_cargo_fmt_and_test_with_config() {
+ assert_parsed(
+ &shlex_split_safe(
+ "cargo fmt -- --config imports_granularity=Item && cargo test -p core --all-features",
+ ),
+ vec![
+ ParsedCommand::Format {
+ cmd: shlex_split_safe("cargo fmt -- --config imports_granularity=Item"),
+ tool: Some("cargo fmt".to_string()),
+ targets: None,
+ },
+ ParsedCommand::Test {
+ cmd: vec_str(&["cargo", "test", "-p", "core", "--all-features"]),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn recognizes_rustfmt_and_clippy() {
+ assert_parsed(
+ &shlex_split_safe("rustfmt src/main.rs"),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["rustfmt", "src/main.rs"]),
+ tool: Some("rustfmt".to_string()),
+ targets: Some(vec!["src/main.rs".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("cargo clippy -p core --all-features -- -D warnings"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&[
+ "cargo",
+ "clippy",
+ "-p",
+ "core",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ]),
+ tool: Some("cargo clippy".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_pytest_go_and_tools() {
+ assert_parsed(
+ &shlex_split_safe(
+ "pytest -k 'Login and not slow' tests/test_login.py::TestLogin::test_ok",
+ ),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&[
+ "pytest",
+ "-k",
+ "Login and not slow",
+ "tests/test_login.py::TestLogin::test_ok",
+ ]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("go fmt ./..."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["go", "fmt", "./..."]),
+ tool: Some("go fmt".to_string()),
+ targets: Some(vec!["./...".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("go test ./pkg -run TestThing"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["go", "test", "./pkg", "-run", "TestThing"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("eslint . --max-warnings 0"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["eslint", ".", "--max-warnings", "0"]),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("prettier -w ."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["prettier", "-w", "."]),
+ tool: Some("prettier".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_jest_and_vitest_filters() {
+ assert_parsed(
+ &shlex_split_safe("jest -t 'should work' src/foo.test.ts"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["jest", "-t", "should work", "src/foo.test.ts"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("vitest -t 'runs' src/foo.test.tsx"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["vitest", "-t", "runs", "src/foo.test.tsx"]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_npx_and_scripts() {
+ assert_parsed(
+ &shlex_split_safe("npx eslint src"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["npx", "eslint", "src"]),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("npx prettier -c ."),
+ vec![ParsedCommand::Format {
+ cmd: vec_str(&["npx", "prettier", "-c", "."]),
+ tool: Some("prettier".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("pnpm run lint -- --max-warnings 0"),
+ vec![ParsedCommand::Lint {
+ cmd: vec_str(&["pnpm", "run", "lint", "--", "--max-warnings", "0"]),
+ tool: Some("pnpm-script:lint".to_string()),
+ targets: None,
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("npm test"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["npm", "test"]),
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("yarn test"),
+ vec![ParsedCommand::Test {
+ cmd: vec_str(&["yarn", "test"]),
+ }],
+ );
+ }
+
+ // ---- is_small_formatting_command unit tests ----
+ #[test]
+ fn small_formatting_always_true_commands() {
+ for cmd in [
+ "wc", "tr", "cut", "sort", "uniq", "xargs", "tee", "column", "awk",
+ ] {
+ assert!(is_small_formatting_command(&shlex_split_safe(cmd)));
+ assert!(is_small_formatting_command(&shlex_split_safe(&format!(
+ "{cmd} -x"
+ ))));
+ }
+ }
+
+ #[test]
+ fn head_behavior() {
+ // No args -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["head"])));
+ // Numeric count only -> not considered small formatting by implementation
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "head -n 40"
+ )));
+ // With explicit file -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "head -n 40 file.txt"
+ )));
+ // File only (no count) -> treated as small formatting by implementation
+ assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"])));
+ }
+
+ #[test]
+ fn tail_behavior() {
+ // No args -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["tail"])));
+ // Numeric with plus offset -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n +10"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n +10 file.txt"
+ )));
+ // Numeric count
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n 30"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "tail -n 30 file.txt"
+ )));
+ // File only -> small formatting by implementation
+ assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"])));
+ }
+
+ #[test]
+ fn sed_behavior() {
+ // Plain sed -> small formatting
+ assert!(is_small_formatting_command(&vec_str(&["sed"])));
+ // sed -n (no file) -> still small formatting
+ assert!(is_small_formatting_command(&vec_str(&["sed", "-n", "10p"])));
+ // Valid range with file -> not small formatting
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "sed -n 10p file.txt"
+ )));
+ assert!(!is_small_formatting_command(&shlex_split_safe(
+ "sed -n 1,200p file.txt"
+ )));
+ // Invalid ranges with file -> small formatting
+ assert!(is_small_formatting_command(&shlex_split_safe(
+ "sed -n p file.txt"
+ )));
+ assert!(is_small_formatting_command(&shlex_split_safe(
+ "sed -n +10p file.txt"
+ )));
+ }
+
+ #[test]
+ fn empty_tokens_is_not_small() {
+ let empty: Vec = Vec::new();
+ assert!(!is_small_formatting_command(&empty));
+ }
+
+ #[test]
+ fn supports_nl_then_sed_reading() {
+ let inner = "nl -ba core/src/parse_command.rs | sed -n '1200,1720p'";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "parse_command.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_sed_n() {
+ let inner = "sed -n '2000,2200p' tui/src/history_cell.rs";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(inner),
+ name: "history_cell.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn filters_out_printf() {
+ let inner =
+ r#"printf "\n===== ansi-escape/Cargo.toml =====\n"; cat -- ansi-escape/Cargo.toml"#;
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("cat -- ansi-escape/Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn drops_yes_in_pipelines() {
+ // Inside bash -lc, `yes | rg --files` should focus on the primary command.
+ let inner = "yes | rg --files";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: vec_str(&["rg", "--files"]),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn supports_sed_n_then_nl_as_search() {
+ // Ensure `sed -n '' | nl -ba` is summarized as a search for that file.
+ let args = shlex_split_safe(
+ "sed -n '260,640p' exec/src/event_processor_with_human_output.rs | nl -ba",
+ );
+ assert_parsed(
+ &args,
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(
+ "sed -n '260,640p' exec/src/event_processor_with_human_output.rs",
+ ),
+ name: "event_processor_with_human_output.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn preserves_rg_with_spaces() {
+ assert_parsed(
+ &shlex_split_safe("yes | rg -n 'foo bar' -S"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg -n 'foo bar' -S"),
+ query: Some("foo bar".to_string()),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn ls_with_glob() {
+ assert_parsed(
+ &shlex_split_safe("ls -I '*.test.js'"),
+ vec![ParsedCommand::ListFiles {
+ cmd: shlex_split_safe("ls -I '*.test.js'"),
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn trim_on_semicolon() {
+ assert_parsed(
+ &shlex_split_safe("rg foo ; echo done"),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg foo"),
+ query: Some("foo".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("echo done"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn split_on_or_connector() {
+ // Ensure we split commands on the logical OR operator as well.
+ assert_parsed(
+ &shlex_split_safe("rg foo || echo done"),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg foo"),
+ query: Some("foo".to_string()),
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("echo done"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn strips_true_in_sequence() {
+ // `true` should be dropped from parsed sequences
+ assert_parsed(
+ &shlex_split_safe("true && rg --files"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+
+ assert_parsed(
+ &shlex_split_safe("rg --files && true"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn strips_true_inside_bash_lc() {
+ let inner = "true && rg --files";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+
+ let inner2 = "rg --files || true";
+ assert_parsed(
+ &vec_str(&["bash", "-lc", inner2]),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn shorten_path_on_windows() {
+ assert_parsed(
+ &shlex_split_safe(r#"cat "pkg\src\main.rs""#),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe(r#"cat "pkg\src\main.rs""#),
+ name: "main.rs".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn head_with_no_space() {
+ assert_parsed(
+ &shlex_split_safe("bash -lc 'head -n50 Cargo.toml'"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("head -n50 Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn bash_dash_c_pipeline_parsing() {
+ // Ensure -c is handled similarly to -lc by normalization
+ let inner = "rg --files | head -n 1";
+ assert_parsed(
+ &shlex_split_safe(inner),
+ vec![
+ ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("head -n 1"),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn tail_with_no_space() {
+ assert_parsed(
+ &shlex_split_safe("bash -lc 'tail -n+10 README.md'"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("tail -n+10 README.md"),
+ name: "README.md".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn pnpm_test_is_parsed_as_test() {
+ assert_parsed(
+ &shlex_split_safe("pnpm test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pnpm test"),
+ }],
+ );
+ }
+
+ #[test]
+ fn pnpm_exec_vitest_is_unknown() {
+ // From commands_combined: cd codex-cli && pnpm exec vitest run tests/... --threads=false --passWithNoTests
+ let inner = "cd codex-cli && pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests";
+ assert_parsed(
+ &shlex_split_safe(inner),
+ vec![
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe("cd codex-cli"),
+ },
+ ParsedCommand::Unknown {
+ cmd: shlex_split_safe(
+ "pnpm exec vitest run tests/file-tag-utils.test.ts --threads=false --passWithNoTests",
+ ),
+ },
+ ],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate() {
+ assert_parsed(
+ &shlex_split_safe("cargo test -p codex-core parse_command::"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test -p codex-core parse_command::"),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_2() {
+ assert_parsed(
+ &shlex_split_safe(
+ "cd core && cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
+ ),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe(
+ "cargo test -q parse_command::tests::bash_dash_c_pipeline_parsing parse_command::tests::fd_file_finder_variants",
+ ),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_3() {
+ assert_parsed(
+ &shlex_split_safe("cd core && cargo test -q parse_command::tests"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test -q parse_command::tests"),
+ }],
+ );
+ }
+
+ #[test]
+ fn cargo_test_with_crate_4() {
+ assert_parsed(
+ &shlex_split_safe("cd core && cargo test --all-features parse_command -- --nocapture"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("cargo test --all-features parse_command -- --nocapture"),
+ }],
+ );
+ }
+
+ // Additional coverage for other common tools/frameworks
+ #[test]
+ fn recognizes_black_and_ruff() {
+ // black formats Python code
+ assert_parsed(
+ &shlex_split_safe("black src"),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("black src"),
+ tool: Some("black".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+
+ // ruff check is a linter; ensure we collect targets
+ assert_parsed(
+ &shlex_split_safe("ruff check ."),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("ruff check ."),
+ tool: Some("ruff".to_string()),
+ targets: Some(vec![".".to_string()]),
+ }],
+ );
+
+ // ruff format is a formatter
+ assert_parsed(
+ &shlex_split_safe("ruff format pkg/"),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("ruff format pkg/"),
+ tool: Some("ruff".to_string()),
+ targets: Some(vec!["pkg/".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn recognizes_pnpm_monorepo_test_and_npm_format_script() {
+ // pnpm -r test in a monorepo should still parse as a test action
+ assert_parsed(
+ &shlex_split_safe("pnpm -r test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pnpm -r test"),
+ }],
+ );
+
+ // npm run format should be recognized as a format action
+ assert_parsed(
+ &shlex_split_safe("npm run format -- -w ."),
+ vec![ParsedCommand::Format {
+ cmd: shlex_split_safe("npm run format -- -w ."),
+ tool: Some("npm-script:format".to_string()),
+ targets: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn yarn_test_is_parsed_as_test() {
+ assert_parsed(
+ &shlex_split_safe("yarn test"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("yarn test"),
+ }],
+ );
+ }
+
+ #[test]
+ fn pytest_file_only_and_go_run_regex() {
+ // pytest invoked with a file path should be captured as a filter
+ assert_parsed(
+ &shlex_split_safe("pytest tests/test_example.py"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("pytest tests/test_example.py"),
+ }],
+ );
+
+ // go test with -run regex should capture the filter
+ assert_parsed(
+ &shlex_split_safe("go test ./... -run '^TestFoo$'"),
+ vec![ParsedCommand::Test {
+ cmd: shlex_split_safe("go test ./... -run '^TestFoo$'"),
+ }],
+ );
+ }
+
+ #[test]
+ fn grep_with_query_and_path() {
+ assert_parsed(
+ &shlex_split_safe("grep -R TODO src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("grep -R TODO src"),
+ query: Some("TODO".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn rg_with_equals_style_flags() {
+ assert_parsed(
+ &shlex_split_safe("rg --colors=never -n foo src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --colors=never -n foo src"),
+ query: Some("foo".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn cat_with_double_dash_and_sed_ranges() {
+ // cat -- should be treated as a read of that file
+ assert_parsed(
+ &shlex_split_safe("cat -- ./-strange-file-name"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("cat -- ./-strange-file-name"),
+ name: "-strange-file-name".to_string(),
+ }],
+ );
+
+ // sed -n should be treated as a read of
+ assert_parsed(
+ &shlex_split_safe("sed -n '12,20p' Cargo.toml"),
+ vec![ParsedCommand::Read {
+ cmd: shlex_split_safe("sed -n '12,20p' Cargo.toml"),
+ name: "Cargo.toml".to_string(),
+ }],
+ );
+ }
+
+ #[test]
+ fn drop_trailing_nl_in_pipeline() {
+ // When an `nl` stage has only flags, it should be dropped from the summary
+ assert_parsed(
+ &shlex_split_safe("rg --files | nl -ba"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("rg --files"),
+ query: None,
+ path: None,
+ }],
+ );
+ }
+
+ #[test]
+ fn ls_with_time_style_and_path() {
+ assert_parsed(
+ &shlex_split_safe("ls --time-style=long-iso ./dist"),
+ vec![ParsedCommand::ListFiles {
+ cmd: shlex_split_safe("ls --time-style=long-iso ./dist"),
+ // short_display_path drops "dist" and shows "." as the last useful segment
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn eslint_with_config_path_and_target() {
+ assert_parsed(
+ &shlex_split_safe("eslint -c .eslintrc.json src"),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("eslint -c .eslintrc.json src"),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn npx_eslint_with_config_path_and_target() {
+ assert_parsed(
+ &shlex_split_safe("npx eslint -c .eslintrc src"),
+ vec![ParsedCommand::Lint {
+ cmd: shlex_split_safe("npx eslint -c .eslintrc src"),
+ tool: Some("eslint".to_string()),
+ targets: Some(vec!["src".to_string()]),
+ }],
+ );
+ }
+
+ #[test]
+ fn fd_file_finder_variants() {
+ assert_parsed(
+ &shlex_split_safe("fd -t f src/"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("fd -t f src/"),
+ query: None,
+ path: Some("src".to_string()),
+ }],
+ );
+
+ // fd with query and path should capture both
+ assert_parsed(
+ &shlex_split_safe("fd main src"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("fd main src"),
+ query: Some("main".to_string()),
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn find_basic_name_filter() {
+ assert_parsed(
+ &shlex_split_safe("find . -name '*.rs'"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("find . -name '*.rs'"),
+ query: Some("*.rs".to_string()),
+ path: Some(".".to_string()),
+ }],
+ );
+ }
+
+ #[test]
+ fn find_type_only_path() {
+ assert_parsed(
+ &shlex_split_safe("find src -type f"),
+ vec![ParsedCommand::Search {
+ cmd: shlex_split_safe("find src -type f"),
+ query: None,
+ path: Some("src".to_string()),
+ }],
+ );
+ }
+}
+
+pub fn parse_command_impl(command: &[String]) -> Vec {
+ let normalized = normalize_tokens(command);
+
+ if let Some(commands) = parse_bash_lc_commands(command, &normalized) {
+ return commands;
+ }
+
+ let parts = if contains_connectors(&normalized) {
+ split_on_connectors(&normalized)
+ } else {
+ vec![normalized.clone()]
+ };
+
+ // Preserve left-to-right execution order for all commands, including bash -c/-lc
+ // so summaries reflect the order they will run.
+
+ // Map each pipeline segment to its parsed summary.
+ let mut parsed: Vec = parts
+ .iter()
+ .map(|tokens| summarize_main_tokens(tokens))
+ .collect();
+
+ // If a pipeline ends with `nl` using only flags (e.g., `| nl -ba`), drop it so the
+ // main action (e.g., a sed range over a file) is surfaced cleanly.
+ if parsed.len() >= 2 {
+ let has_and_and = normalized.iter().any(|t| t == "&&");
+ let contains_test = parsed
+ .iter()
+ .any(|pc| matches!(pc, ParsedCommand::Test { .. }));
+ parsed.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ if let Some(first) = cmd.first() {
+ // Drop cosmetic echo segments in chained commands
+ if has_and_and && first == "echo" {
+ return false;
+ }
+ // In non-bash chained commands, ignore directory changes like `cd foo`
+ // when the sequence includes a recognized test command. Preserve `cd`
+ // for other sequences (e.g., followed by a search command).
+ if has_and_and && contains_test && first == "cd" {
+ return false;
+ }
+ // Drop no-op commands like `true`
+ if cmd.len() == 1 && first == "true" {
+ return false;
+ }
+ if first == "nl" {
+ // Treat `nl` without an explicit file operand as formatting-only.
+ return cmd.iter().skip(1).any(|a| !a.starts_with('-'));
+ }
+ }
+ true
+ }
+ _ => true,
+ });
+ }
+
+ // Also drop standalone `true` commands when not part of a chained `&&` context above
+ parsed.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ !(cmd.len() == 1 && cmd.first().is_some_and(|s| s == "true"))
+ }
+ _ => true,
+ });
+
+ parsed
+}
+
+/// Validates that this is a `sed -n 123,123p` command.
+fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {
+ let s = match arg {
+ Some(s) => s,
+ None => return false,
+ };
+ let core = match s.strip_suffix('p') {
+ Some(rest) => rest,
+ None => return false,
+ };
+ let parts: Vec<&str> = core.split(',').collect();
+ match parts.as_slice() {
+ [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
+ [a, b] => {
+ !a.is_empty()
+ && !b.is_empty()
+ && a.chars().all(|c| c.is_ascii_digit())
+ && b.chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ }
+}
+
+/// Normalize a command by:
+/// - Removing `yes`/`no`/`bash -c`/`bash -lc` prefixes.
+/// - Splitting on `|` and `&&`/`||`/`;
+fn normalize_tokens(cmd: &[String]) -> Vec {
+ match cmd {
+ [first, pipe, rest @ ..] if (first == "yes" || first == "y") && pipe == "|" => {
+ // Do not re-shlex already-tokenized input; just drop the prefix.
+ rest.to_vec()
+ }
+ [first, pipe, rest @ ..] if (first == "no" || first == "n") && pipe == "|" => {
+ // Do not re-shlex already-tokenized input; just drop the prefix.
+ rest.to_vec()
+ }
+ [bash, flag, script] if bash == "bash" && (flag == "-c" || flag == "-lc") => {
+ shlex_split(script)
+ .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()])
+ }
+ _ => cmd.to_vec(),
+ }
+}
+
+fn contains_connectors(tokens: &[String]) -> bool {
+ tokens
+ .iter()
+ .any(|t| t == "&&" || t == "||" || t == "|" || t == ";")
+}
+
+fn split_on_connectors(tokens: &[String]) -> Vec> {
+ let mut out: Vec> = Vec::new();
+ let mut cur: Vec = Vec::new();
+ for t in tokens {
+ if t == "&&" || t == "||" || t == "|" || t == ";" {
+ if !cur.is_empty() {
+ out.push(std::mem::take(&mut cur));
+ }
+ } else {
+ cur.push(t.clone());
+ }
+ }
+ if !cur.is_empty() {
+ out.push(cur);
+ }
+ out
+}
+
+fn trim_at_connector(tokens: &[String]) -> Vec {
+ let idx = tokens
+ .iter()
+ .position(|t| t == "|" || t == "&&" || t == "||" || t == ";")
+ .unwrap_or(tokens.len());
+ tokens[..idx].to_vec()
+}
+
+/// Shorten a path to the last component, excluding `build`/`dist`/`node_modules`/`src`.
+/// It also pulls out a useful path from a directory such as:
+/// - webview/src -> webview
+/// - foo/src/ -> foo
+/// - packages/app/node_modules/ -> app
+fn short_display_path(path: &str) -> String {
+ // Normalize separators and drop any trailing slash for display.
+ let normalized = path.replace('\\', "/");
+ let trimmed = normalized.trim_end_matches('/');
+ let mut parts = trimmed.split('/').rev().filter(|p| {
+ !p.is_empty() && *p != "build" && *p != "dist" && *p != "node_modules" && *p != "src"
+ });
+ parts
+ .next()
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| trimmed.to_string())
+}
+
+// Skip values consumed by specific flags and ignore --flag=value style arguments.
+fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
+ let mut out: Vec<&'a String> = Vec::new();
+ let mut skip_next = false;
+ for (i, a) in args.iter().enumerate() {
+ if skip_next {
+ skip_next = false;
+ continue;
+ }
+ if a == "--" {
+ // From here on, everything is positional operands; push the rest and break.
+ for rest in &args[i + 1..] {
+ out.push(rest);
+ }
+ break;
+ }
+ if a.starts_with("--") && a.contains('=') {
+ // --flag=value form: treat as a flag taking a value; skip entirely.
+ continue;
+ }
+ if flags_with_vals.contains(&a.as_str()) {
+ // This flag consumes the next argument as its value.
+ if i + 1 < args.len() {
+ skip_next = true;
+ }
+ continue;
+ }
+ out.push(a);
+ }
+ out
+}
+
+/// Common flags for ESLint that take a following value and should not be
+/// considered positional targets.
+const ESLINT_FLAGS_WITH_VALUES: &[&str] = &[
+ "-c",
+ "--config",
+ "--parser",
+ "--parser-options",
+ "--rulesdir",
+ "--plugin",
+ "--max-warnings",
+ "--format",
+];
+
+fn collect_non_flag_targets(args: &[String]) -> Option> {
+ let mut targets = Vec::new();
+ let mut skip_next = false;
+ for (i, a) in args.iter().enumerate() {
+ if a == "--" {
+ break;
+ }
+ if skip_next {
+ skip_next = false;
+ continue;
+ }
+ if a == "-p"
+ || a == "--package"
+ || a == "--features"
+ || a == "-C"
+ || a == "--config"
+ || a == "--config-path"
+ || a == "--out-dir"
+ || a == "-o"
+ || a == "--run"
+ || a == "--max-warnings"
+ || a == "--format"
+ {
+ if i + 1 < args.len() {
+ skip_next = true;
+ }
+ continue;
+ }
+ if a.starts_with('-') {
+ continue;
+ }
+ targets.push(a.clone());
+ }
+ if targets.is_empty() {
+ None
+ } else {
+ Some(targets)
+ }
+}
+
+fn collect_non_flag_targets_with_flags(
+ args: &[String],
+ flags_with_vals: &[&str],
+) -> Option> {
+ let targets: Vec = skip_flag_values(args, flags_with_vals)
+ .into_iter()
+ .filter(|a| !a.starts_with('-'))
+ .cloned()
+ .collect();
+ if targets.is_empty() {
+ None
+ } else {
+ Some(targets)
+ }
+}
+
+fn is_pathish(s: &str) -> bool {
+ s == "."
+ || s == ".."
+ || s.starts_with("./")
+ || s.starts_with("../")
+ || s.contains('/')
+ || s.contains('\\')
+}
+
+fn parse_fd_query_and_path(tail: &[String]) -> (Option, Option) {
+ let args_no_connector = trim_at_connector(tail);
+ // fd has several flags that take values (e.g., -t/--type, -e/--extension).
+ // Skip those values when extracting positional operands.
+ let candidates = skip_flag_values(
+ &args_no_connector,
+ &[
+ "-t",
+ "--type",
+ "-e",
+ "--extension",
+ "-E",
+ "--exclude",
+ "--search-path",
+ ],
+ );
+ let non_flags: Vec<&String> = candidates
+ .into_iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ match non_flags.as_slice() {
+ [one] => {
+ if is_pathish(one) {
+ (None, Some(short_display_path(one)))
+ } else {
+ (Some((*one).clone()), None)
+ }
+ }
+ [q, p, ..] => (Some((*q).clone()), Some(short_display_path(p))),
+ _ => (None, None),
+ }
+}
+
+fn parse_find_query_and_path(tail: &[String]) -> (Option, Option) {
+ let args_no_connector = trim_at_connector(tail);
+ // First positional argument (excluding common unary operators) is the root path
+ let mut path: Option = None;
+ for a in &args_no_connector {
+ if !a.starts_with('-') && *a != "!" && *a != "(" && *a != ")" {
+ path = Some(short_display_path(a));
+ break;
+ }
+ }
+ // Extract a common name/path/regex pattern if present
+ let mut query: Option = None;
+ let mut i = 0;
+ while i < args_no_connector.len() {
+ let a = &args_no_connector[i];
+ if a == "-name" || a == "-iname" || a == "-path" || a == "-regex" {
+ if i + 1 < args_no_connector.len() {
+ query = Some(args_no_connector[i + 1].clone());
+ }
+ break;
+ }
+ i += 1;
+ }
+ (query, path)
+}
+
+fn classify_npm_like(tool: &str, tail: &[String], full_cmd: &[String]) -> Option {
+ let mut r = tail;
+ if tool == "pnpm" && r.first().map(|s| s.as_str()) == Some("-r") {
+ r = &r[1..];
+ }
+ let mut script_name: Option = None;
+ if r.first().map(|s| s.as_str()) == Some("run") {
+ script_name = r.get(1).cloned();
+ } else {
+ let is_test_cmd = (tool == "npm" && r.first().map(|s| s.as_str()) == Some("t"))
+ || ((tool == "npm" || tool == "pnpm" || tool == "yarn")
+ && r.first().map(|s| s.as_str()) == Some("test"));
+ if is_test_cmd {
+ script_name = Some("test".to_string());
+ }
+ }
+ if let Some(name) = script_name {
+ let lname = name.to_lowercase();
+ if lname == "test" || lname == "unit" || lname == "jest" || lname == "vitest" {
+ return Some(ParsedCommand::Test {
+ cmd: full_cmd.to_vec(),
+ });
+ }
+ if lname == "lint" || lname == "eslint" {
+ return Some(ParsedCommand::Lint {
+ cmd: full_cmd.to_vec(),
+ tool: Some(format!("{tool}-script:{name}")),
+ targets: None,
+ });
+ }
+ if lname == "format" || lname == "fmt" || lname == "prettier" {
+ return Some(ParsedCommand::Format {
+ cmd: full_cmd.to_vec(),
+ tool: Some(format!("{tool}-script:{name}")),
+ targets: None,
+ });
+ }
+ }
+ None
+}
+
+fn parse_bash_lc_commands(
+ original: &[String],
+ normalized: &[String],
+) -> Option> {
+ let [bash, flag, script] = original else {
+ return None;
+ };
+ if bash != "bash" || flag != "-lc" {
+ return None;
+ }
+ if let Some(tree) = try_parse_bash(script) {
+ if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
+ if !all_commands.is_empty() {
+ let script_tokens = shlex_split(script)
+ .unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
+ // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
+ // bias toward the primary command when pipelines are present.
+ // First, drop obvious small formatting helpers (e.g., wc/awk/etc).
+ let had_multiple_commands = all_commands.len() > 1;
+ // The bash AST walker yields commands in right-to-left order for
+ // connector/pipeline sequences. Reverse to reflect actual execution order.
+ let mut filtered_commands = drop_small_formatting_commands(all_commands);
+ filtered_commands.reverse();
+ if filtered_commands.is_empty() {
+ return Some(vec![ParsedCommand::Unknown {
+ cmd: normalized.to_vec(),
+ }]);
+ }
+ let mut commands: Vec = filtered_commands
+ .into_iter()
+ .map(|tokens| summarize_main_tokens(&tokens))
+ .collect();
+ // Drop no-op `true` commands
+ commands.retain(|pc| match pc {
+ ParsedCommand::Unknown { cmd } => {
+ !(cmd.len() == 1 && cmd.first().is_some_and(|s| s == "true"))
+ }
+ _ => true,
+ });
+ commands = maybe_collapse_cat_sed(commands, &script_tokens);
+ if commands.len() == 1 {
+ // If we reduced to a single command, attribute the full original script
+ // for clearer UX in file-reading and listing scenarios, or when there were
+ // no connectors in the original script. For search commands that came from
+ // a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
+ let had_connectors = had_multiple_commands
+ || script_tokens
+ .iter()
+ .any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
+ commands = commands
+ .into_iter()
+ .map(|pc| match pc {
+ ParsedCommand::Read { name, cmd } => {
+ if had_connectors {
+ let has_pipe = script_tokens.iter().any(|t| t == "|");
+ let has_sed_n = script_tokens.windows(2).any(|w| {
+ w.first().map(|s| s.as_str()) == Some("sed")
+ && w.get(1).map(|s| s.as_str()) == Some("-n")
+ });
+ if has_pipe && has_sed_n {
+ ParsedCommand::Read {
+ cmd: script_tokens.clone(),
+ name,
+ }
+ } else {
+ ParsedCommand::Read { cmd, name }
+ }
+ } else {
+ ParsedCommand::Read {
+ cmd: script_tokens.clone(),
+ name,
+ }
+ }
+ }
+ ParsedCommand::ListFiles { path, cmd } => {
+ if had_connectors {
+ ParsedCommand::ListFiles { cmd, path }
+ } else {
+ ParsedCommand::ListFiles {
+ cmd: script_tokens.clone(),
+ path,
+ }
+ }
+ }
+ ParsedCommand::Search { cmd, query, path } => {
+ if had_connectors {
+ ParsedCommand::Search { cmd, query, path }
+ } else {
+ ParsedCommand::Search {
+ cmd: script_tokens.clone(),
+ query,
+ path,
+ }
+ }
+ }
+ ParsedCommand::Format { tool, targets, .. } => ParsedCommand::Format {
+ cmd: script_tokens.clone(),
+ tool,
+ targets,
+ },
+ ParsedCommand::Test { .. } => ParsedCommand::Test {
+ cmd: script_tokens.clone(),
+ },
+ ParsedCommand::Lint { tool, targets, .. } => ParsedCommand::Lint {
+ cmd: script_tokens.clone(),
+ tool,
+ targets,
+ },
+ ParsedCommand::Unknown { .. } => ParsedCommand::Unknown {
+ cmd: script_tokens.clone(),
+ },
+ })
+ .collect();
+ }
+ return Some(commands);
+ }
+ }
+ }
+ Some(vec![ParsedCommand::Unknown {
+ cmd: normalized.to_vec(),
+ }])
+}
+
+/// Return true if this looks like a small formatting helper in a pipeline.
+/// Examples: `head -n 40`, `tail -n +10`, `wc -l`, `awk ...`, `cut ...`, `tr ...`.
+/// We try to keep variants that clearly include a file path (e.g. `tail -n 30 file`).
+fn is_small_formatting_command(tokens: &[String]) -> bool {
+ if tokens.is_empty() {
+ return false;
+ }
+ let cmd = tokens[0].as_str();
+ match cmd {
+ // Always formatting; typically used in pipes.
+ // `nl` is special-cased below to allow `nl ` to be treated as a read command.
+ "wc" | "tr" | "cut" | "sort" | "uniq" | "xargs" | "tee" | "column" | "awk" | "yes"
+ | "printf" => true,
+ "head" => {
+ // Treat as formatting when no explicit file operand is present.
+ // Common forms: `head -n 40`, `head -c 100`.
+ // Keep cases like `head -n 40 file`.
+ tokens.len() < 3
+ }
+ "tail" => {
+ // Treat as formatting when no explicit file operand is present.
+ // Common forms: `tail -n +10`, `tail -n 30`.
+ // Keep cases like `tail -n 30 file`.
+ tokens.len() < 3
+ }
+ "sed" => {
+ // Keep `sed -n file` (treated as a file read elsewhere);
+ // otherwise consider it a formatting helper in a pipeline.
+ tokens.len() < 4
+ || !(tokens[1] == "-n" && is_valid_sed_n_arg(tokens.get(2).map(|s| s.as_str())))
+ }
+ _ => false,
+ }
+}
+
+fn drop_small_formatting_commands(mut commands: Vec>) -> Vec> {
+ commands.retain(|tokens| !is_small_formatting_command(tokens));
+ commands
+}
+
+fn maybe_collapse_cat_sed(
+ commands: Vec,
+ script_tokens: &[String],
+) -> Vec {
+ if commands.len() < 2 {
+ return commands;
+ }
+ let drop_leading_sed = match (&commands[0], &commands[1]) {
+ (ParsedCommand::Unknown { cmd: sed_cmd }, ParsedCommand::Read { cmd: cat_cmd, .. }) => {
+ let is_sed_n = sed_cmd.first().map(|s| s.as_str()) == Some("sed")
+ && sed_cmd.get(1).map(|s| s.as_str()) == Some("-n")
+ && is_valid_sed_n_arg(sed_cmd.get(2).map(|s| s.as_str()))
+ && sed_cmd.len() == 3;
+ let is_cat_file =
+ cat_cmd.first().map(|s| s.as_str()) == Some("cat") && cat_cmd.len() == 2;
+ is_sed_n && is_cat_file
+ }
+ _ => false,
+ };
+ if drop_leading_sed {
+ if let ParsedCommand::Read { name, .. } = &commands[1] {
+ return vec![ParsedCommand::Read {
+ cmd: script_tokens.to_vec(),
+ name: name.clone(),
+ }];
+ }
+ }
+ commands
+}
+
+fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
+ match main_cmd.split_first() {
+ // (sed-specific logic handled below in dedicated arm returning Read)
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("fmt") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("cargo fmt".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("clippy") =>
+ {
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("cargo clippy".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "cargo" && tail.first().map(|s| s.as_str()) == Some("test") =>
+ {
+ ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "rustfmt" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("rustfmt".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("fmt") => {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("go fmt".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail)) if head == "go" && tail.first().map(|s| s.as_str()) == Some("test") => {
+ ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, _)) if head == "pytest" => ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ },
+ Some((head, tail)) if head == "eslint" => {
+ // Treat configuration flags with values (e.g. `-c .eslintrc`) as non-targets.
+ let targets = collect_non_flag_targets_with_flags(tail, ESLINT_FLAGS_WITH_VALUES);
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("eslint".to_string()),
+ targets,
+ }
+ }
+ Some((head, tail)) if head == "prettier" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("prettier".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail)) if head == "black" => ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("black".to_string()),
+ targets: collect_non_flag_targets(tail),
+ },
+ Some((head, tail))
+ if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("check") =>
+ {
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("ruff".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, tail))
+ if head == "ruff" && tail.first().map(|s| s.as_str()) == Some("format") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("ruff".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ Some((head, _)) if (head == "jest" || head == "vitest") => ParsedCommand::Test {
+ cmd: main_cmd.to_vec(),
+ },
+ Some((head, tail))
+ if head == "npx" && tail.first().map(|s| s.as_str()) == Some("eslint") =>
+ {
+ let targets = collect_non_flag_targets_with_flags(&tail[1..], ESLINT_FLAGS_WITH_VALUES);
+ ParsedCommand::Lint {
+ cmd: main_cmd.to_vec(),
+ tool: Some("eslint".to_string()),
+ targets,
+ }
+ }
+ Some((head, tail))
+ if head == "npx" && tail.first().map(|s| s.as_str()) == Some("prettier") =>
+ {
+ ParsedCommand::Format {
+ cmd: main_cmd.to_vec(),
+ tool: Some("prettier".to_string()),
+ targets: collect_non_flag_targets(&tail[1..]),
+ }
+ }
+ // NPM-like scripts including yarn
+ Some((tool, tail)) if (tool == "pnpm" || tool == "npm" || tool == "yarn") => {
+ if let Some(cmd) = classify_npm_like(tool, tail, main_cmd) {
+ cmd
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail)) if head == "ls" => {
+ // Avoid treating option values as paths (e.g., ls -I "*.test.js").
+ let candidates = skip_flag_values(
+ tail,
+ &[
+ "-I",
+ "-w",
+ "--block-size",
+ "--format",
+ "--time-style",
+ "--color",
+ "--quoting-style",
+ ],
+ );
+ let path = candidates
+ .into_iter()
+ .find(|p| !p.starts_with('-'))
+ .map(|p| short_display_path(p));
+ ParsedCommand::ListFiles {
+ cmd: main_cmd.to_vec(),
+ path,
+ }
+ }
+ Some((head, tail)) if head == "rg" => {
+ let args_no_connector = trim_at_connector(tail);
+ let has_files_flag = args_no_connector.iter().any(|a| a == "--files");
+ let non_flags: Vec<&String> = args_no_connector
+ .iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ let (query, path) = if has_files_flag {
+ (None, non_flags.first().map(|s| short_display_path(s)))
+ } else {
+ (
+ non_flags.first().cloned().map(|s| s.to_string()),
+ non_flags.get(1).map(|s| short_display_path(s)),
+ )
+ };
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "fd" => {
+ let (query, path) = parse_fd_query_and_path(tail);
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "find" => {
+ // Basic find support: capture path and common name filter
+ let (query, path) = parse_find_query_and_path(tail);
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "grep" => {
+ let args_no_connector = trim_at_connector(tail);
+ let non_flags: Vec<&String> = args_no_connector
+ .iter()
+ .filter(|p| !p.starts_with('-'))
+ .collect();
+ // Do not shorten the query: grep patterns may legitimately contain slashes
+ // and should be preserved verbatim. Only paths should be shortened.
+ let query = non_flags.first().cloned().map(|s| s.to_string());
+ let path = non_flags.get(1).map(|s| short_display_path(s));
+ ParsedCommand::Search {
+ cmd: main_cmd.to_vec(),
+ query,
+ path,
+ }
+ }
+ Some((head, tail)) if head == "cat" => {
+ // Support both `cat ` and `cat -- ` forms.
+ let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") {
+ &tail[1..]
+ } else {
+ tail
+ };
+ if effective_tail.len() == 1 {
+ let name = short_display_path(&effective_tail[0]);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail)) if head == "head" => {
+ // Support `head -n 50 file` and `head -n50 file` forms.
+ let has_valid_n = match tail.split_first() {
+ Some((first, rest)) if first == "-n" => rest
+ .first()
+ .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())),
+ Some((first, _)) if first.starts_with("-n") => {
+ first[2..].chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ };
+ if has_valid_n {
+ // Build candidates skipping the numeric value consumed by `-n` when separated.
+ let mut candidates: Vec<&String> = Vec::new();
+ let mut i = 0;
+ while i < tail.len() {
+ if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
+ let n = &tail[i + 1];
+ if n.chars().all(|c| c.is_ascii_digit()) {
+ i += 2;
+ continue;
+ }
+ }
+ candidates.push(&tail[i]);
+ i += 1;
+ }
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ return ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ };
+ }
+ }
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "tail" => {
+ // Support `tail -n +10 file` and `tail -n+10 file` forms.
+ let has_valid_n = match tail.split_first() {
+ Some((first, rest)) if first == "-n" => rest.first().is_some_and(|n| {
+ let s = n.strip_prefix('+').unwrap_or(n);
+ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
+ }),
+ Some((first, _)) if first.starts_with("-n") => {
+ let v = &first[2..];
+ let s = v.strip_prefix('+').unwrap_or(v);
+ !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
+ }
+ _ => false,
+ };
+ if has_valid_n {
+ // Build candidates skipping the numeric value consumed by `-n` when separated.
+ let mut candidates: Vec<&String> = Vec::new();
+ let mut i = 0;
+ while i < tail.len() {
+ if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
+ let n = &tail[i + 1];
+ let s = n.strip_prefix('+').unwrap_or(n);
+ if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
+ i += 2;
+ continue;
+ }
+ }
+ candidates.push(&tail[i]);
+ i += 1;
+ }
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ return ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ };
+ }
+ }
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ Some((head, tail)) if head == "nl" => {
+ // Avoid treating option values as paths (e.g., nl -s " ").
+ let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]);
+ if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
+ let name = short_display_path(p);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ Some((head, tail))
+ if head == "sed"
+ && tail.len() >= 3
+ && tail[0] == "-n"
+ && is_valid_sed_n_arg(tail.get(1).map(|s| s.as_str())) =>
+ {
+ if let Some(path) = tail.get(2) {
+ let name = short_display_path(path);
+ ParsedCommand::Read {
+ cmd: main_cmd.to_vec(),
+ name,
+ }
+ } else {
+ ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ }
+ }
+ }
+ // Other commands
+ _ => ParsedCommand::Unknown {
+ cmd: main_cmd.to_vec(),
+ },
+ }
+}
diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs
index bba5363266..9e81abcd88 100644
--- a/codex-rs/core/src/plan_tool.rs
+++ b/codex-rs/core/src/plan_tool.rs
@@ -42,7 +42,9 @@ pub(crate) static PLAN_TOOL: LazyLock = LazyLock::new(|| {
plan_item_props.insert("step".to_string(), JsonSchema::String { description: None });
plan_item_props.insert(
"status".to_string(),
- JsonSchema::String { description: None },
+ JsonSchema::String {
+ description: Some("One of: pending, in_progress, completed".to_string()),
+ },
);
let plan_items_schema = JsonSchema::Array {
@@ -63,17 +65,11 @@ pub(crate) static PLAN_TOOL: LazyLock = LazyLock::new(|| {
OpenAiTool::Function(ResponsesApiTool {
name: "update_plan".to_string(),
- description: r#"Use the update_plan tool to keep the user updated on the current plan for the task.
-After understanding the user's task, call the update_plan tool with an initial plan. An example of a plan:
-1. Explore the codebase to find relevant files (status: in_progress)
-2. Implement the feature in the XYZ component (status: pending)
-3. Commit changes and make a pull request (status: pending)
-Each step should be a short, 1-sentence description.
-Until all the steps are finished, there should always be exactly one in_progress step in the plan.
-Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`.
-Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step.
-Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
-When all steps are completed, call update_plan one last time with all steps marked as `completed`."#.to_string(),
+ description: r#"Updates the task plan.
+Provide an optional explanation and a list of plan items, each with a step and status.
+At most one step can be in_progress at a time.
+"#
+ .to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
index 9008ad307d..e984b95f98 100644
--- a/codex-rs/core/src/protocol.rs
+++ b/codex-rs/core/src/protocol.rs
@@ -21,6 +21,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::model_provider_info::ModelProviderInfo;
+use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Submission Queue Entry - requests from user
@@ -234,8 +235,7 @@ impl SandboxPolicy {
}
}
- /// Always returns `true` for now, as we do not yet support restricting read
- /// access.
+ /// Always returns `true`; restricting read access is not supported.
pub fn has_full_disk_read_access(&self) -> bool {
true
}
@@ -383,6 +383,8 @@ pub enum EventMsg {
/// Agent reasoning content delta event from agent.
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
+ /// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
+ AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
/// Ack the client's configure message.
SessionConfigured(SessionConfiguredEvent),
@@ -530,6 +532,9 @@ pub struct AgentReasoningRawContentDeltaEvent {
pub delta: String,
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AgentReasoningSectionBreakEvent {}
+
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentReasoningDeltaEvent {
pub delta: String,
@@ -579,6 +584,7 @@ pub struct ExecCommandBeginEvent {
pub command: Vec,
/// The command's working directory if not the default cwd for the agent.
pub cwd: PathBuf,
+ pub parsed_cmd: Vec,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
index de0764f75e..4a4e0146da 100644
--- a/codex-rs/core/src/shell.rs
+++ b/codex-rs/core/src/shell.rs
@@ -167,9 +167,6 @@ mod tests {
for (input, expected_cmd, expected_output) in cases {
use std::collections::HashMap;
use std::path::PathBuf;
- use std::sync::Arc;
-
- use tokio::sync::Notify;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
@@ -219,7 +216,6 @@ mod tests {
justification: None,
},
SandboxType::None,
- Arc::new(Notify::new()),
&SandboxPolicy::DangerFullAccess,
&None,
None,
@@ -230,7 +226,7 @@ mod tests {
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
- output.stdout, expected,
+ output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}
diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs
new file mode 100644
index 0000000000..a0cf387069
--- /dev/null
+++ b/codex-rs/core/src/user_agent.rs
@@ -0,0 +1,36 @@
+const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
+
+pub fn get_codex_user_agent(originator: Option<&str>) -> String {
+ let build_version = env!("CARGO_PKG_VERSION");
+ let os_info = os_info::get();
+ format!(
+ "{}/{build_version} ({} {}; {})",
+ originator.unwrap_or(DEFAULT_ORIGINATOR),
+ os_info.os_type(),
+ os_info.version(),
+ os_info.architecture().unwrap_or("unknown"),
+ )
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_get_codex_user_agent() {
+ let user_agent = get_codex_user_agent(None);
+ assert!(user_agent.starts_with("codex_cli_rs/"));
+ }
+
+ #[test]
+ #[cfg(target_os = "macos")]
+ fn test_macos() {
+ use regex_lite::Regex;
+ let user_agent = get_codex_user_agent(None);
+ let re =
+ Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$")
+ .unwrap();
+ assert!(re.is_match(&user_agent));
+ }
+}
diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs
index fb5f45de6f..e1248a4867 100644
--- a/codex-rs/core/src/util.rs
+++ b/codex-rs/core/src/util.rs
@@ -1,32 +1,11 @@
use std::path::Path;
-use std::sync::Arc;
use std::time::Duration;
use rand::Rng;
-use tokio::sync::Notify;
-use tracing::debug;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
-/// Make a CancellationToken that is fulfilled when SIGINT occurs.
-pub fn notify_on_sigint() -> Arc {
- let notify = Arc::new(Notify::new());
-
- tokio::spawn({
- let notify = Arc::clone(¬ify);
- async move {
- loop {
- tokio::signal::ctrl_c().await.ok();
- debug!("Keyboard interrupt");
- notify.notify_waiters();
- }
- }
- });
-
- notify
-}
-
pub(crate) fn backoff(attempt: u64) -> Duration {
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs
index bd7ad2feef..1bcddf0796 100644
--- a/codex-rs/core/tests/client.rs
+++ b/codex-rs/core/tests/client.rs
@@ -1,14 +1,13 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
+use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
+use codex_core::NewConversation;
use codex_core::WireApi;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
-use codex_core::protocol::SessionConfiguredEvent;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
@@ -90,14 +89,15 @@ async fn includes_session_id_and_model_headers_in_request() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- ctrl_c.clone(),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let NewConversation {
+ conversation: codex,
+ conversation_id,
+ session_configured: _,
+ } = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .expect("create new conversation");
codex
.submit(Op::UserInput {
@@ -108,13 +108,6 @@ async fn includes_session_id_and_model_headers_in_request() {
.await
.unwrap();
- let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
- wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await
- else {
- unreachable!()
- };
-
- let current_session_id = Some(session_id.to_string());
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
@@ -123,10 +116,9 @@ async fn includes_session_id_and_model_headers_in_request() {
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
- assert!(current_session_id.is_some());
assert_eq!(
request_session_id.to_str().unwrap(),
- current_session_id.as_ref().unwrap()
+ conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
assert_eq!(
@@ -164,14 +156,12 @@ async fn includes_base_instructions_override_in_request() {
config.base_instructions = Some("test instructions".to_string());
config.model_provider = model_provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- ctrl_c.clone(),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .expect("create new conversation")
+ .conversation;
codex
.submit(Op::UserInput {
@@ -223,14 +213,12 @@ async fn originator_config_override_is_used() {
config.model_provider = model_provider;
config.internal_originator = Some("my_override".to_string());
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- ctrl_c.clone(),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .expect("create new conversation")
+ .conversation;
codex
.submit(Op::UserInput {
@@ -283,11 +271,15 @@ async fn chatgpt_auth_sends_correct_request() {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } =
- Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let NewConversation {
+ conversation: codex,
+ conversation_id,
+ session_configured: _,
+ } = conversation_manager
+ .new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
+ .await
+ .expect("create new conversation");
codex
.submit(Op::UserInput {
@@ -298,13 +290,6 @@ async fn chatgpt_auth_sends_correct_request() {
.await
.unwrap();
- let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) =
- wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await
- else {
- unreachable!()
- };
-
- let current_session_id = Some(session_id.to_string());
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
@@ -315,10 +300,9 @@ async fn chatgpt_auth_sends_correct_request() {
let request_chatgpt_account_id = request.headers.get("chatgpt-account-id").unwrap();
let request_body = request.body_json::().unwrap();
- assert!(current_session_id.is_some());
assert_eq!(
request_session_id.to_str().unwrap(),
- current_session_id.as_ref().unwrap()
+ conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
assert_eq!(
@@ -361,14 +345,12 @@ async fn includes_user_instructions_message_in_request() {
config.model_provider = model_provider;
config.user_instructions = Some("be nice".to_string());
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- ctrl_c.clone(),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .expect("create new conversation")
+ .conversation;
codex
.submit(Op::UserInput {
@@ -457,8 +439,12 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(config, None, ctrl_c.clone()).await.unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, None)
+ .await
+ .expect("create new conversation")
+ .conversation;
codex
.submit(Op::UserInput {
@@ -531,11 +517,12 @@ async fn env_var_overrides_loaded_auth() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } =
- Codex::spawn(config, Some(create_dummy_codex_auth()), ctrl_c.clone())
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
+ .await
+ .expect("create new conversation")
+ .conversation;
codex
.submit(Op::UserInput {
diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs
index 834ec38298..04b6dc4b62 100644
--- a/codex-rs/core/tests/common/lib.rs
+++ b/codex-rs/core/tests/common/lib.rs
@@ -2,6 +2,7 @@
use tempfile::TempDir;
+use codex_core::CodexConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -72,7 +73,7 @@ pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) ->
}
pub async fn wait_for_event(
- codex: &codex_core::Codex,
+ codex: &CodexConversation,
predicate: F,
) -> codex_core::protocol::EventMsg
where
@@ -83,16 +84,18 @@ where
}
pub async fn wait_for_event_with_timeout(
- codex: &codex_core::Codex,
+ codex: &CodexConversation,
mut predicate: F,
wait_time: tokio::time::Duration,
) -> codex_core::protocol::EventMsg
where
F: FnMut(&codex_core::protocol::EventMsg) -> bool,
{
+ use tokio::time::Duration;
use tokio::time::timeout;
loop {
- let ev = timeout(wait_time, codex.next_event())
+ // Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery)
+ let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event())
.await
.expect("timeout waiting for event")
.expect("stream ended unexpectedly");
diff --git a/codex-rs/core/tests/compact.rs b/codex-rs/core/tests/compact.rs
index fa5c81d883..28b1ca8d74 100644
--- a/codex-rs/core/tests/compact.rs
+++ b/codex-rs/core/tests/compact.rs
@@ -1,7 +1,6 @@
#![expect(clippy::unwrap_used)]
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
+use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::protocol::EventMsg;
@@ -142,14 +141,12 @@ async fn summarize_context_three_requests_and_instructions() {
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("dummy")),
- ctrl_c.clone(),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("dummy")))
+ .await
+ .unwrap()
+ .conversation;
// 1) Normal user input – should hit server once.
codex
diff --git a/codex-rs/core/tests/exec.rs b/codex-rs/core/tests/exec.rs
index f1b9e78e67..ff5d2b7d54 100644
--- a/codex-rs/core/tests/exec.rs
+++ b/codex-rs/core/tests/exec.rs
@@ -1,25 +1,30 @@
#![cfg(target_os = "macos")]
-#![expect(clippy::expect_used)]
+#![expect(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
-use std::sync::Arc;
use codex_core::exec::ExecParams;
+use codex_core::exec::ExecToolCallOutput;
use codex_core::exec::SandboxType;
use codex_core::exec::process_exec_tool_call;
use codex_core::protocol::SandboxPolicy;
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use tempfile::TempDir;
-use tokio::sync::Notify;
+
+use codex_core::error::Result;
use codex_core::get_platform_sandbox;
-async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
+fn skip_test() -> bool {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
- return;
+ return true;
}
+ false
+}
+
+async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result {
let sandbox_type = get_platform_sandbox().expect("should be able to get sandbox type");
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
@@ -32,34 +37,84 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
justification: None,
};
- let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
- let result = process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await;
-
- assert!(result.is_ok() == should_be_ok);
+ process_exec_tool_call(params, sandbox_type, &policy, &None, None).await
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn exit_code_0_succeeds() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["echo", "hello"];
- run_test_cmd(tmp, cmd, true).await
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+ assert_eq!(output.stdout.text, "hello\n");
+ assert_eq!(output.stderr.text, "");
+ assert_eq!(output.stdout.truncated_after_lines, None);
+}
+
+/// Command succeeds with exit code 0 normally
+#[tokio::test]
+async fn truncates_output_lines() {
+ if skip_test() {
+ return;
+ }
+
+ let tmp = TempDir::new().expect("should be able to create temp dir");
+ let cmd = vec!["seq", "300"];
+
+ #[expect(clippy::unwrap_used)]
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+
+ let expected_output = (1..=256)
+ .map(|i| format!("{i}\n"))
+ .collect::>()
+ .join("");
+ assert_eq!(output.stdout.text, expected_output);
+ assert_eq!(output.stdout.truncated_after_lines, Some(256));
+}
+
+/// Command succeeds with exit code 0 normally
+#[tokio::test]
+async fn truncates_output_bytes() {
+ if skip_test() {
+ return;
+ }
+
+ let tmp = TempDir::new().expect("should be able to create temp dir");
+ // each line is 1000 bytes
+ let cmd = vec!["bash", "-lc", "seq 15 | awk '{printf \"%-1000s\\n\", $0}'"];
+
+ let output = run_test_cmd(tmp, cmd).await.unwrap();
+
+ assert_eq!(output.stdout.text.len(), 10240);
+ assert_eq!(output.stdout.truncated_after_lines, Some(10));
}
/// Command not found returns exit code 127, this is not considered a sandbox error
#[tokio::test]
async fn exit_command_not_found_is_ok() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"];
- run_test_cmd(tmp, cmd, true).await
+ run_test_cmd(tmp, cmd).await.unwrap();
}
/// Writing a file fails and should be considered a sandbox error
#[tokio::test]
async fn write_file_fails_as_sandbox_error() {
+ if skip_test() {
+ return;
+ }
+
let tmp = TempDir::new().expect("should be able to create temp dir");
let path = tmp.path().join("test.txt");
let cmd = vec![
@@ -67,5 +122,5 @@ async fn write_file_fails_as_sandbox_error() {
path.to_str().expect("should be able to get path"),
];
- run_test_cmd(tmp, cmd, false).await;
+ assert!(run_test_cmd(tmp, cmd).await.is_err());
}
diff --git a/codex-rs/core/tests/exec_stream_events.rs b/codex-rs/core/tests/exec_stream_events.rs
index 534b25513a..85d4eb3701 100644
--- a/codex-rs/core/tests/exec_stream_events.rs
+++ b/codex-rs/core/tests/exec_stream_events.rs
@@ -2,7 +2,6 @@
use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::Arc;
use async_channel::Receiver;
use codex_core::exec::ExecParams;
@@ -14,7 +13,6 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandOutputDeltaEvent;
use codex_core::protocol::ExecOutputStream;
use codex_core::protocol::SandboxPolicy;
-use tokio::sync::Notify;
fn collect_stdout_events(rx: Receiver) -> Vec {
let mut out = Vec::new();
@@ -57,13 +55,11 @@ async fn test_exec_stdout_stream_events_echo() {
justification: None,
};
- let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(
params,
SandboxType::None,
- ctrl_c,
&policy,
&None,
Some(stdout_stream),
@@ -76,7 +72,7 @@ async fn test_exec_stdout_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
- assert_eq!(result.stdout, "hello-world\n");
+ assert_eq!(result.stdout.text, "hello-world\n");
let streamed = collect_stdout_events(rx);
// We should have received at least the same contents (possibly in one chunk)
@@ -109,13 +105,11 @@ async fn test_exec_stderr_stream_events_echo() {
justification: None,
};
- let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(
params,
SandboxType::None,
- ctrl_c,
&policy,
&None,
Some(stdout_stream),
@@ -128,8 +122,8 @@ async fn test_exec_stderr_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
- assert_eq!(result.stdout, "");
- assert_eq!(result.stderr, "oops\n");
+ assert_eq!(result.stdout.text, "");
+ assert_eq!(result.stderr.text, "oops\n");
// Collect only stderr delta events
let mut err = Vec::new();
diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs
deleted file mode 100644
index 81b3bb2a12..0000000000
--- a/codex-rs/core/tests/live_agent.rs
+++ /dev/null
@@ -1,209 +0,0 @@
-#![expect(clippy::unwrap_used, clippy::expect_used)]
-
-//! Live integration tests that exercise the full [`Agent`] stack **against the real
-//! OpenAI `/v1/responses` API**. These tests complement the lightweight mock‑based
-//! unit tests by verifying that the agent can drive an end‑to‑end conversation,
-//! stream incremental events, execute function‑call tool invocations and safely
-//! chain multiple turns inside a single session – the exact scenarios that have
-//! historically been brittle.
-//!
-//! The live tests are **ignored by default** so CI remains deterministic and free
-//! of external dependencies. Developers can opt‑in locally with e.g.
-//!
-//! ```bash
-//! OPENAI_API_KEY=sk‑... cargo test --test live_agent -- --ignored --nocapture
-//! ```
-//!
-//! Make sure your key has access to the experimental *Responses* API and that
-//! any billable usage is acceptable.
-
-use std::time::Duration;
-
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
-use codex_core::error::CodexErr;
-use codex_core::protocol::AgentMessageEvent;
-use codex_core::protocol::ErrorEvent;
-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 tempfile::TempDir;
-use tokio::sync::Notify;
-use tokio::time::timeout;
-
-fn api_key_available() -> bool {
- std::env::var("OPENAI_API_KEY").is_ok()
-}
-
-/// Helper that spawns a fresh Agent and sends the mandatory *ConfigureSession*
-/// submission. The caller receives the constructed [`Agent`] plus the unique
-/// submission id used for the initialization message.
-async fn spawn_codex() -> Result {
- assert!(
- api_key_available(),
- "OPENAI_API_KEY must be set for live tests"
- );
-
- let codex_home = TempDir::new().unwrap();
- let mut config = load_default_config_for_test(&codex_home);
- config.model_provider.request_max_retries = Some(2);
- config.model_provider.stream_max_retries = Some(2);
- let CodexSpawnOk { codex: agent, .. } =
- Codex::spawn(config, None, std::sync::Arc::new(Notify::new())).await?;
-
- Ok(agent)
-}
-
-/// Verifies that the agent streams incremental *AgentMessage* events **before**
-/// emitting `TaskComplete` and that a second task inside the same session does
-/// not get tripped up by a stale `previous_response_id`.
-#[ignore]
-#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn live_streaming_and_prev_id_reset() {
- if !api_key_available() {
- eprintln!("skipping live_streaming_and_prev_id_reset – OPENAI_API_KEY not set");
- return;
- }
-
- let codex = spawn_codex().await.unwrap();
-
- // ---------- Task 1 ----------
- codex
- .submit(Op::UserInput {
- items: vec![InputItem::Text {
- text: "Say the words 'stream test'".into(),
- }],
- })
- .await
- .unwrap();
-
- let mut saw_message_before_complete = false;
- loop {
- let ev = timeout(Duration::from_secs(60), codex.next_event())
- .await
- .expect("timeout waiting for task1 events")
- .expect("agent closed");
-
- match ev.msg {
- EventMsg::AgentMessage(_) => saw_message_before_complete = true,
- EventMsg::TaskComplete(_) => break,
- EventMsg::Error(ErrorEvent { message }) => {
- panic!("agent reported error in task1: {message}")
- }
- _ => {
- // Ignore other events.
- }
- }
- }
-
- assert!(
- saw_message_before_complete,
- "Agent did not stream any AgentMessage before TaskComplete"
- );
-
- // ---------- Task 2 (same session) ----------
- codex
- .submit(Op::UserInput {
- items: vec![InputItem::Text {
- text: "Respond with exactly: second turn succeeded".into(),
- }],
- })
- .await
- .unwrap();
-
- let mut got_expected = false;
- loop {
- let ev = timeout(Duration::from_secs(60), codex.next_event())
- .await
- .expect("timeout waiting for task2 events")
- .expect("agent closed");
-
- match &ev.msg {
- EventMsg::AgentMessage(AgentMessageEvent { message })
- if message.contains("second turn succeeded") =>
- {
- got_expected = true;
- }
- EventMsg::TaskComplete(_) => break,
- EventMsg::Error(ErrorEvent { message }) => {
- panic!("agent reported error in task2: {message}")
- }
- _ => {
- // Ignore other events.
- }
- }
- }
-
- assert!(got_expected, "second task did not receive expected answer");
-}
-
-/// Exercises a *function‑call → shell execution* round‑trip by instructing the
-/// model to run a harmless `echo` command. The test asserts that:
-/// 1. the function call is executed (we see `ExecCommandBegin`/`End` events)
-/// 2. the captured stdout reaches the client unchanged.
-#[ignore]
-#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn live_shell_function_call() {
- if !api_key_available() {
- eprintln!("skipping live_shell_function_call – OPENAI_API_KEY not set");
- return;
- }
-
- let codex = spawn_codex().await.unwrap();
-
- const MARKER: &str = "codex_live_echo_ok";
-
- codex
- .submit(Op::UserInput {
- items: vec![InputItem::Text {
- text: format!(
- "Use the shell function to run the command `echo {MARKER}` and no other commands."
- ),
- }],
- })
- .await
- .unwrap();
-
- let mut saw_begin = false;
- let mut saw_end_with_output = false;
-
- loop {
- let ev = timeout(Duration::from_secs(60), codex.next_event())
- .await
- .expect("timeout waiting for function‑call events")
- .expect("agent closed");
-
- match ev.msg {
- EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent {
- command,
- ..
- }) => {
- assert_eq!(command, vec!["echo", MARKER]);
- saw_begin = true;
- }
- EventMsg::ExecCommandEnd(codex_core::protocol::ExecCommandEndEvent {
- stdout,
- exit_code,
- ..
- }) => {
- assert_eq!(exit_code, 0, "echo returned non‑zero exit code");
- assert!(stdout.contains(MARKER));
- saw_end_with_output = true;
- }
- EventMsg::TaskComplete(_) => break,
- EventMsg::Error(codex_core::protocol::ErrorEvent { message }) => {
- panic!("agent error during shell test: {message}")
- }
- _ => {
- // Ignore other events.
- }
- }
- }
-
- assert!(saw_begin, "ExecCommandBegin event missing");
- assert!(
- saw_end_with_output,
- "ExecCommandEnd with expected output missing"
- );
-}
diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs
new file mode 100644
index 0000000000..8df7ea353d
--- /dev/null
+++ b/codex-rs/core/tests/prompt_caching.rs
@@ -0,0 +1,134 @@
+#![allow(clippy::expect_used, clippy::unwrap_used)]
+
+use codex_core::ConversationManager;
+use codex_core::ModelProviderInfo;
+use codex_core::built_in_model_providers;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::InputItem;
+use codex_core::protocol::Op;
+use codex_login::CodexAuth;
+use core_test_support::load_default_config_for_test;
+use core_test_support::load_sse_fixture_with_id;
+use core_test_support::wait_for_event;
+use tempfile::TempDir;
+use wiremock::Mock;
+use wiremock::MockServer;
+use wiremock::ResponseTemplate;
+use wiremock::matchers::method;
+use wiremock::matchers::path;
+
+/// Build minimal SSE stream with completed marker using the JSON fixture.
+fn sse_completed(id: &str) -> String {
+ load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn prefixes_context_and_instructions_once_and_consistently_across_requests() {
+ #![allow(clippy::unwrap_used)]
+ use pretty_assertions::assert_eq;
+
+ let server = MockServer::start().await;
+
+ let sse = sse_completed("resp");
+ let template = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(sse, "text/event-stream");
+
+ // Expect two POSTs to /v1/responses
+ Mock::given(method("POST"))
+ .and(path("/v1/responses"))
+ .respond_with(template)
+ .expect(2)
+ .mount(&server)
+ .await;
+
+ let model_provider = ModelProviderInfo {
+ base_url: Some(format!("{}/v1", server.uri())),
+ ..built_in_model_providers()["openai"].clone()
+ };
+
+ let cwd = TempDir::new().unwrap();
+ let codex_home = TempDir::new().unwrap();
+ let mut config = load_default_config_for_test(&codex_home);
+ config.cwd = cwd.path().to_path_buf();
+ config.model_provider = model_provider;
+ config.user_instructions = Some("be consistent and helpful".to_string());
+
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .expect("create new conversation")
+ .conversation;
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![InputItem::Text {
+ text: "hello 1".into(),
+ }],
+ })
+ .await
+ .unwrap();
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![InputItem::Text {
+ text: "hello 2".into(),
+ }],
+ })
+ .await
+ .unwrap();
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
+
+ let requests = server.received_requests().await.unwrap();
+ assert_eq!(requests.len(), 2, "expected two POST requests");
+
+ let expected_env_text = format!(
+ "\n\nCurrent working directory: {}\nApproval policy: on-request\nSandbox policy: read-only\nNetwork access: restricted\n\n\n",
+ cwd.path().to_string_lossy()
+ );
+ let expected_ui_text =
+ "\n\nbe consistent and helpful\n\n";
+
+ let expected_env_msg = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": expected_env_text } ]
+ });
+ let expected_ui_msg = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": expected_ui_text } ]
+ });
+
+ let expected_user_message_1 = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": "hello 1" } ]
+ });
+ let body1 = requests[0].body_json::().unwrap();
+ assert_eq!(
+ body1["input"],
+ serde_json::json!([expected_env_msg, expected_ui_msg, expected_user_message_1])
+ );
+
+ let expected_user_message_2 = serde_json::json!({
+ "type": "message",
+ "id": serde_json::Value::Null,
+ "role": "user",
+ "content": [ { "type": "input_text", "text": "hello 2" } ]
+ });
+ let body2 = requests[1].body_json::().unwrap();
+ let expected_body2 = serde_json::json!(
+ [
+ body1["input"].as_array().unwrap().as_slice(),
+ [expected_user_message_2].as_slice(),
+ ]
+ .concat()
+ );
+ assert_eq!(body2["input"], expected_body2);
+}
diff --git a/codex-rs/core/tests/stream_error_allows_next_turn.rs b/codex-rs/core/tests/stream_error_allows_next_turn.rs
index 1500c789e9..d590c43312 100644
--- a/codex-rs/core/tests/stream_error_allows_next_turn.rs
+++ b/codex-rs/core/tests/stream_error_allows_next_turn.rs
@@ -1,7 +1,6 @@
use std::time::Duration;
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
+use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::protocol::EventMsg;
@@ -90,13 +89,12 @@ async fn continue_after_stream_error() {
config.base_instructions = Some("You are a helpful assistant".to_string());
config.model_provider = provider;
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- std::sync::Arc::new(tokio::sync::Notify::new()),
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .unwrap()
+ .conversation;
codex
.submit(Op::UserInput {
diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs
index 0ded3337ab..5fa8ab8ba1 100644
--- a/codex-rs/core/tests/stream_no_completed.rs
+++ b/codex-rs/core/tests/stream_no_completed.rs
@@ -3,8 +3,7 @@
use std::time::Duration;
-use codex_core::Codex;
-use codex_core::CodexSpawnOk;
+use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
@@ -93,17 +92,15 @@ async fn retries_on_early_close() {
requires_openai_auth: false,
};
- let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
- let CodexSpawnOk { codex, .. } = Codex::spawn(
- config,
- Some(CodexAuth::from_api_key("Test API Key")),
- ctrl_c,
- )
- .await
- .unwrap();
+ let conversation_manager = ConversationManager::default();
+ let codex = conversation_manager
+ .new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
+ .await
+ .unwrap()
+ .conversation;
codex
.submit(Op::UserInput {
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index a2ae813183..22cf130462 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -210,6 +210,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
#[allow(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
+ EventMsg::AgentReasoningSectionBreak(_) => {
+ if !self.show_agent_reasoning {
+ return CodexStatus::Running;
+ }
+ println!();
+ #[allow(clippy::expect_used)]
+ std::io::stdout().flush().expect("could not flush stdout");
+ }
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if !self.show_raw_agent_reasoning {
return CodexStatus::Running;
@@ -255,6 +263,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id,
command,
cwd,
+ parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id.clone(),
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index 6ed57898b2..ff6123d74b 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -6,12 +6,11 @@ mod event_processor_with_json_output;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
-use std::sync::Arc;
pub use cli::Cli;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
-use codex_core::codex_wrapper::CodexConversation;
-use codex_core::codex_wrapper::{self};
+use codex_core::ConversationManager;
+use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_types::SandboxMode;
@@ -185,35 +184,30 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
std::process::exit(1);
}
- let CodexConversation {
- codex: codex_wrapper,
+ let conversation_manager = ConversationManager::default();
+ let NewConversation {
+ conversation_id: _,
+ conversation,
session_configured,
- ctrl_c,
- ..
- } = codex_wrapper::init_codex(config).await?;
- let codex = Arc::new(codex_wrapper);
+ } = conversation_manager.new_conversation(config).await?;
info!("Codex initialized with event: {session_configured:?}");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::();
{
- let codex = codex.clone();
+ let conversation = conversation.clone();
tokio::spawn(async move {
loop {
- let interrupted = ctrl_c.notified();
tokio::select! {
- _ = interrupted => {
- // Forward an interrupt to the codex so it can abort any in‑flight task.
- let _ = codex
- .submit(
- Op::Interrupt,
- )
- .await;
+ _ = tokio::signal::ctrl_c() => {
+ tracing::debug!("Keyboard interrupt");
+ // Immediately notify Codex to abort any in‑flight task.
+ conversation.submit(Op::Interrupt).await.ok();
- // Exit the inner loop and return to the main input prompt. The codex
+ // Exit the inner loop and return to the main input prompt. The codex
// will emit a `TurnInterrupted` (Error) event which is drained later.
break;
}
- res = codex.next_event() => match res {
+ res = conversation.next_event() => match res {
Ok(event) => {
debug!("Received event: {event:?}");
@@ -243,9 +237,9 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
.into_iter()
.map(|path| InputItem::LocalImage { path })
.collect();
- let initial_images_event_id = codex.submit(Op::UserInput { items }).await?;
+ let initial_images_event_id = conversation.submit(Op::UserInput { items }).await?;
info!("Sent images with event ID: {initial_images_event_id}");
- while let Ok(event) = codex.next_event().await {
+ while let Ok(event) = conversation.next_event().await {
if event.id == initial_images_event_id
&& matches!(
event.msg,
@@ -261,7 +255,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
// Send the prompt.
let items: Vec = vec![InputItem::Text { text: prompt }];
- let initial_prompt_task_id = codex.submit(Op::UserInput { items }).await?;
+ let initial_prompt_task_id = conversation.submit(Op::UserInput { items }).await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
// Run the loop until the task is complete.
@@ -270,7 +264,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any
match shutdown {
CodexStatus::Running => continue,
CodexStatus::InitiateShutdown => {
- codex.submit(Op::Shutdown).await?;
+ conversation.submit(Op::Shutdown).await?;
}
CodexStatus::Shutdown => {
break;
diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs
index 96298c6563..76de2547a6 100644
--- a/codex-rs/linux-sandbox/tests/landlock.rs
+++ b/codex-rs/linux-sandbox/tests/landlock.rs
@@ -11,9 +11,7 @@ use codex_core::exec_env::create_env;
use codex_core::protocol::SandboxPolicy;
use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::Arc;
use tempfile::NamedTempFile;
-use tokio::sync::Notify;
// At least on GitHub CI, the arm64 tests appear to need longer timeouts.
@@ -59,11 +57,9 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
};
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
- let ctrl_c = Arc::new(Notify::new());
let res = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
- ctrl_c,
&sandbox_policy,
&codex_linux_sandbox_exe,
None,
@@ -72,8 +68,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
.unwrap();
if res.exit_code != 0 {
- println!("stdout:\n{}", res.stdout);
- println!("stderr:\n{}", res.stderr);
+ println!("stdout:\n{}", res.stdout.text);
+ println!("stderr:\n{}", res.stderr.text);
panic!("exit code: {}", res.exit_code);
}
}
@@ -150,13 +146,11 @@ async fn assert_network_blocked(cmd: &[&str]) {
};
let sandbox_policy = SandboxPolicy::new_read_only_policy();
- let ctrl_c = Arc::new(Notify::new());
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
let codex_linux_sandbox_exe: Option = Some(PathBuf::from(sandbox_program));
let result = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
- ctrl_c,
&sandbox_policy,
&codex_linux_sandbox_exe,
None,
@@ -164,7 +158,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
.await;
let (exit_code, stdout, stderr) = match result {
- Ok(output) => (output.exit_code, output.stdout, output.stderr),
+ Ok(output) => (output.exit_code, output.stdout.text, output.stderr.text),
Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => {
(exit_code, stdout, stderr)
}
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
index 899451a50d..4af3e29c48 100644
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
@@ -34,7 +34,7 @@ pub struct CodexToolCallParam {
pub cwd: Option,
/// Approval policy for shell commands generated by the model:
- /// `untrusted`, `on-failure`, `never`.
+ /// `untrusted`, `on-failure`, `on-request`, `never`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option,
@@ -63,6 +63,7 @@ pub struct CodexToolCallParam {
pub enum CodexToolCallApprovalPolicy {
Untrusted,
OnFailure,
+ OnRequest,
Never,
}
@@ -71,6 +72,7 @@ impl From for AskForApproval {
match value {
CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
+ CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
}
}
@@ -244,10 +246,11 @@ mod tests {
"type": "object",
"properties": {
"approval-policy": {
- "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`.",
+ "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
"enum": [
"untrusted",
"on-failure",
+ "on-request",
"never"
],
"type": "string"
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index b91c4a7609..ced01539bb 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -5,12 +5,13 @@
use std::collections::HashMap;
use std::sync::Arc;
-use codex_core::Codex;
-use codex_core::codex_wrapper::CodexConversation;
-use codex_core::codex_wrapper::init_codex;
+use codex_core::CodexConversation;
+use codex_core::ConversationManager;
+use codex_core::NewConversation;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::InputItem;
@@ -41,15 +42,14 @@ pub async fn run_codex_tool_session(
initial_prompt: String,
config: CodexConfig,
outgoing: Arc,
- session_map: Arc>>>,
+ conversation_manager: Arc,
running_requests_id_to_codex_uuid: Arc>>,
) {
- let CodexConversation {
- codex,
+ let NewConversation {
+ conversation_id,
+ conversation,
session_configured,
- session_id,
- ..
- } = match init_codex(config).await {
+ } = match conversation_manager.new_conversation(config).await {
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
@@ -65,16 +65,15 @@ pub async fn run_codex_tool_session(
return;
}
};
- let codex = Arc::new(codex);
-
- // update the session map so we can retrieve the session in a reply, and then drop it, since
- // we no longer need it for this function
- session_map.lock().await.insert(session_id, codex.clone());
- drop(session_map);
+ let session_configured_event = Event {
+ // Use a fake id value for now.
+ id: "".to_string(),
+ msg: EventMsg::SessionConfigured(session_configured.clone()),
+ };
outgoing
.send_event_as_notification(
- &session_configured,
+ &session_configured_event,
Some(OutgoingNotificationMeta::new(Some(id.clone()))),
)
.await;
@@ -89,7 +88,7 @@ pub async fn run_codex_tool_session(
running_requests_id_to_codex_uuid
.lock()
.await
- .insert(id.clone(), session_id);
+ .insert(id.clone(), conversation_id);
let submission = Submission {
id: sub_id.clone(),
op: Op::UserInput {
@@ -99,18 +98,24 @@ pub async fn run_codex_tool_session(
},
};
- if let Err(e) = codex.submit_with_id(submission).await {
+ if let Err(e) = conversation.submit_with_id(submission).await {
tracing::error!("Failed to submit initial prompt: {e}");
// unregister the id so we don't keep it in the map
running_requests_id_to_codex_uuid.lock().await.remove(&id);
return;
}
- run_codex_tool_session_inner(codex, outgoing, id, running_requests_id_to_codex_uuid).await;
+ run_codex_tool_session_inner(
+ conversation,
+ outgoing,
+ id,
+ running_requests_id_to_codex_uuid,
+ )
+ .await;
}
pub async fn run_codex_tool_session_reply(
- codex: Arc,
+ conversation: Arc,
outgoing: Arc,
request_id: RequestId,
prompt: String,
@@ -121,7 +126,7 @@ pub async fn run_codex_tool_session_reply(
.lock()
.await
.insert(request_id.clone(), session_id);
- if let Err(e) = codex
+ if let Err(e) = conversation
.submit(Op::UserInput {
items: vec![InputItem::Text { text: prompt }],
})
@@ -137,7 +142,7 @@ pub async fn run_codex_tool_session_reply(
}
run_codex_tool_session_inner(
- codex,
+ conversation,
outgoing,
request_id,
running_requests_id_to_codex_uuid,
@@ -146,7 +151,7 @@ pub async fn run_codex_tool_session_reply(
}
async fn run_codex_tool_session_inner(
- codex: Arc,
+ codex: Arc,
outgoing: Arc,
request_id: RequestId,
running_requests_id_to_codex_uuid: Arc>>,
@@ -257,6 +262,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+ | EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
diff --git a/codex-rs/mcp-server/src/conversation_loop.rs b/codex-rs/mcp-server/src/conversation_loop.rs
index 80c34760c5..61f0c95ad4 100644
--- a/codex-rs/mcp-server/src/conversation_loop.rs
+++ b/codex-rs/mcp-server/src/conversation_loop.rs
@@ -4,7 +4,7 @@ use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
-use codex_core::Codex;
+use codex_core::CodexConversation;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::EventMsg;
@@ -13,7 +13,7 @@ use mcp_types::RequestId;
use tracing::error;
pub async fn run_conversation_loop(
- codex: Arc,
+ codex: Arc,
outgoing: Arc,
request_id: RequestId,
) {
@@ -95,6 +95,7 @@ pub async fn run_conversation_loop(
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+ | EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ExecCommandBegin(_)
diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs
index 54abfb35bd..1985cc79e6 100644
--- a/codex-rs/mcp-server/src/exec_approval.rs
+++ b/codex-rs/mcp-server/src/exec_approval.rs
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
-use codex_core::Codex;
+use codex_core::CodexConversation;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use mcp_types::ElicitRequest;
@@ -51,7 +51,7 @@ pub(crate) async fn handle_exec_approval_request(
command: Vec,
cwd: PathBuf,
outgoing: Arc,
- codex: Arc,
+ codex: Arc,
request_id: RequestId,
tool_call_id: String,
event_id: String,
@@ -116,7 +116,7 @@ pub(crate) async fn handle_exec_approval_request(
async fn on_exec_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver,
- codex: Arc,
+ codex: Arc,
) {
let response = receiver.await;
let value = match response {
diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs
index 2f8858a37b..0528e18a39 100644
--- a/codex-rs/mcp-server/src/mcp_protocol.rs
+++ b/codex-rs/mcp-server/src/mcp_protocol.rs
@@ -936,6 +936,7 @@ mod tests {
call_id: "c1".into(),
command: vec!["bash".into(), "-lc".into(), "echo hi".into()],
cwd: std::path::PathBuf::from("/work"),
+ parsed_cmd: vec![],
}),
};
@@ -947,7 +948,8 @@ mod tests {
"type": "exec_command_begin",
"call_id": "c1",
"command": ["bash", "-lc", "echo hi"],
- "cwd": "/work"
+ "cwd": "/work",
+ "parsed_cmd": []
}
}
});
diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs
index 2f99cda723..a3349aeb35 100644
--- a/codex-rs/mcp-server/src/message_processor.rs
+++ b/codex-rs/mcp-server/src/message_processor.rs
@@ -14,7 +14,7 @@ use crate::outgoing_message::OutgoingMessageSender;
use crate::tool_handlers::create_conversation::handle_create_conversation;
use crate::tool_handlers::send_message::handle_send_message;
-use codex_core::Codex;
+use codex_core::ConversationManager;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::Submission;
use mcp_types::CallToolRequest;
@@ -42,7 +42,7 @@ pub(crate) struct MessageProcessor {
outgoing: Arc,
initialized: bool,
codex_linux_sandbox_exe: Option,
- session_map: Arc>>>,
+ conversation_manager: Arc,
running_requests_id_to_codex_uuid: Arc>>,
running_session_ids: Arc>>,
}
@@ -58,14 +58,14 @@ impl MessageProcessor {
outgoing: Arc::new(outgoing),
initialized: false,
codex_linux_sandbox_exe,
- session_map: Arc::new(Mutex::new(HashMap::new())),
+ conversation_manager: Arc::new(ConversationManager::default()),
running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())),
running_session_ids: Arc::new(Mutex::new(HashSet::new())),
}
}
- pub(crate) fn session_map(&self) -> Arc>>> {
- self.session_map.clone()
+ pub(crate) fn get_conversation_manager(&self) -> &ConversationManager {
+ &self.conversation_manager
}
pub(crate) fn outgoing(&self) -> Arc {
@@ -431,9 +431,9 @@ impl MessageProcessor {
}
};
- // Clone outgoing and session map to move into async task.
+ // Clone outgoing and server to move into async task.
let outgoing = self.outgoing.clone();
- let session_map = self.session_map.clone();
+ let conversation_manager = self.conversation_manager.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
// Spawn an async task to handle the Codex session so that we do not
@@ -445,7 +445,7 @@ impl MessageProcessor {
initial_prompt,
config,
outgoing,
- session_map,
+ conversation_manager,
running_requests_id_to_codex_uuid,
)
.await;
@@ -516,33 +516,27 @@ impl MessageProcessor {
}
};
- // load codex from session map
- let session_map_mutex = Arc::clone(&self.session_map);
-
- // Clone outgoing and session map to move into async task.
+ // Clone outgoing to move into async task.
let outgoing = self.outgoing.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
- let codex = {
- let session_map = session_map_mutex.lock().await;
- match session_map.get(&session_id).cloned() {
- Some(c) => c,
- None => {
- tracing::warn!("Session not found for session_id: {session_id}");
- let result = CallToolResult {
- content: vec![ContentBlock::TextContent(TextContent {
- r#type: "text".to_owned(),
- text: format!("Session not found for session_id: {session_id}"),
- annotations: None,
- })],
- is_error: Some(true),
- structured_content: None,
- };
- outgoing
- .send_response(request_id, serde_json::to_value(result).unwrap_or_default())
- .await;
- return;
- }
+ let codex = match self.conversation_manager.get_conversation(session_id).await {
+ Ok(c) => c,
+ Err(_) => {
+ tracing::warn!("Session not found for session_id: {session_id}");
+ let result = CallToolResult {
+ content: vec![ContentBlock::TextContent(TextContent {
+ r#type: "text".to_owned(),
+ text: format!("Session not found for session_id: {session_id}"),
+ annotations: None,
+ })],
+ is_error: Some(true),
+ structured_content: None,
+ };
+ outgoing
+ .send_response(request_id, serde_json::to_value(result).unwrap_or_default())
+ .await;
+ return;
}
};
@@ -609,15 +603,12 @@ impl MessageProcessor {
};
tracing::info!("session_id: {session_id}");
- // Obtain the Codex Arc while holding the session_map lock, then release.
- let codex_arc = {
- let sessions_guard = self.session_map.lock().await;
- match sessions_guard.get(&session_id) {
- Some(codex) => Arc::clone(codex),
- None => {
- tracing::warn!("Session not found for session_id: {session_id}");
- return;
- }
+ // Obtain the Codex conversation from the server.
+ let codex_arc = match self.conversation_manager.get_conversation(session_id).await {
+ Ok(c) => c,
+ Err(_) => {
+ tracing::warn!("Session not found for session_id: {session_id}");
+ return;
}
};
diff --git a/codex-rs/mcp-server/src/patch_approval.rs b/codex-rs/mcp-server/src/patch_approval.rs
index db99ee5f27..3c614ab331 100644
--- a/codex-rs/mcp-server/src/patch_approval.rs
+++ b/codex-rs/mcp-server/src/patch_approval.rs
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
-use codex_core::Codex;
+use codex_core::CodexConversation;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
@@ -47,7 +47,7 @@ pub(crate) async fn handle_patch_approval_request(
grant_root: Option,
changes: HashMap,
outgoing: Arc,
- codex: Arc,
+ codex: Arc,
request_id: RequestId,
tool_call_id: String,
event_id: String,
@@ -111,7 +111,7 @@ pub(crate) async fn handle_patch_approval_request(
pub(crate) async fn on_patch_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver,
- codex: Arc,
+ codex: Arc,
) {
let response = receiver.await;
let value = match response {
diff --git a/codex-rs/mcp-server/src/tool_handlers/create_conversation.rs b/codex-rs/mcp-server/src/tool_handlers/create_conversation.rs
index 559bf72905..77a68f0128 100644
--- a/codex-rs/mcp-server/src/tool_handlers/create_conversation.rs
+++ b/codex-rs/mcp-server/src/tool_handlers/create_conversation.rs
@@ -1,16 +1,9 @@
-use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::Arc;
-use codex_core::Codex;
-use codex_core::codex_wrapper::init_codex;
+use codex_core::NewConversation;
use codex_core::config::Config as CodexConfig;
use codex_core::config::ConfigOverrides;
-use codex_core::protocol::EventMsg;
-use codex_core::protocol::SessionConfiguredEvent;
use mcp_types::RequestId;
-use tokio::sync::Mutex;
-use uuid::Uuid;
use crate::conversation_loop::run_conversation_loop;
use crate::json_to_toml::json_to_toml;
@@ -81,8 +74,16 @@ pub(crate) async fn handle_create_conversation(
}
};
- // Initialize Codex session
- let codex_conversation = match init_codex(cfg).await {
+ // Initialize Codex session via server API
+ let NewConversation {
+ conversation_id: session_id,
+ conversation,
+ session_configured,
+ } = match message_processor
+ .get_conversation_manager()
+ .new_conversation(cfg)
+ .await
+ {
Ok(conv) => conv,
Err(e) => {
message_processor
@@ -100,41 +101,13 @@ pub(crate) async fn handle_create_conversation(
}
};
- // Expect SessionConfigured; if not, return error.
- let EventMsg::SessionConfigured(SessionConfiguredEvent { model, .. }) =
- &codex_conversation.session_configured.msg
- else {
- message_processor
- .send_response_with_optional_error(
- id,
- Some(ToolCallResponseResult::ConversationCreate(
- ConversationCreateResult::Error {
- message: "Expected SessionConfigured event".to_string(),
- },
- )),
- Some(true),
- )
- .await;
- return;
- };
+ let effective_model = session_configured.model.clone();
- let effective_model = model.clone();
-
- let session_id = codex_conversation.session_id;
- let codex_arc = Arc::new(codex_conversation.codex);
-
- // Store session for future calls
- insert_session(
- session_id,
- codex_arc.clone(),
- message_processor.session_map(),
- )
- .await;
// Run the conversation loop in the background so this request can return immediately.
let outgoing = message_processor.outgoing();
let spawn_id = id.clone();
tokio::spawn(async move {
- run_conversation_loop(codex_arc.clone(), outgoing, spawn_id).await;
+ run_conversation_loop(conversation.clone(), outgoing, spawn_id).await;
});
// Reply with the new conversation id and effective model
@@ -151,12 +124,3 @@ pub(crate) async fn handle_create_conversation(
)
.await;
}
-
-async fn insert_session(
- session_id: Uuid,
- codex: Arc,
- session_map: Arc>>>,
-) {
- let mut guard = session_map.lock().await;
- guard.insert(session_id, codex);
-}
diff --git a/codex-rs/mcp-server/src/tool_handlers/send_message.rs b/codex-rs/mcp-server/src/tool_handlers/send_message.rs
index 894176bef6..985854f852 100644
--- a/codex-rs/mcp-server/src/tool_handlers/send_message.rs
+++ b/codex-rs/mcp-server/src/tool_handlers/send_message.rs
@@ -1,12 +1,6 @@
-use std::collections::HashMap;
-use std::sync::Arc;
-
-use codex_core::Codex;
use codex_core::protocol::Op;
use codex_core::protocol::Submission;
use mcp_types::RequestId;
-use tokio::sync::Mutex;
-use uuid::Uuid;
use crate::mcp_protocol::ConversationSendMessageArgs;
use crate::mcp_protocol::ConversationSendMessageResult;
@@ -41,7 +35,11 @@ pub(crate) async fn handle_send_message(
}
let session_id = conversation_id.0;
- let Some(codex) = get_session(session_id, message_processor.session_map()).await else {
+ let Ok(codex) = message_processor
+ .get_conversation_manager()
+ .get_conversation(session_id)
+ .await
+ else {
message_processor
.send_response_with_optional_error(
id,
@@ -114,11 +112,3 @@ pub(crate) async fn handle_send_message(
)
.await;
}
-
-pub(crate) async fn get_session(
- session_id: Uuid,
- session_map: Arc>>>,
-) -> Option> {
- let guard = session_map.lock().await;
- guard.get(&session_id).cloned()
-}
diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml
index ead9a06494..7fa1328385 100644
--- a/codex-rs/ollama/Cargo.toml
+++ b/codex-rs/ollama/Cargo.toml
@@ -24,7 +24,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
-toml = "0.9.2"
+toml = "0.9.5"
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 719c631149..182667a060 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -14,6 +14,8 @@ path = "src/lib.rs"
[features]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = []
+# Gate verbose debug logging inside the TUI implementation.
+debug-logs = []
[lints]
workspace = true
@@ -39,6 +41,7 @@ crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
lazy_static = "1"
+once_cell = "1"
mcp-types = { path = "../mcp-types" }
path-clean = "1.0.1"
ratatui = { version = "0.29.0", features = [
@@ -72,6 +75,9 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index a97948f3ea..fb45ecfd19 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -9,9 +9,9 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::should_show_login_screen;
use crate::slash_command::SlashCommand;
use crate::tui;
+use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::Event;
-use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
@@ -32,7 +32,7 @@ use std::thread;
use std::time::Duration;
/// Time window for debouncing redraw requests.
-const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
+const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
/// Top-level application state: which full-screen view is currently active.
#[allow(clippy::large_enum_variant)]
@@ -49,6 +49,7 @@ enum AppState<'a> {
}
pub(crate) struct App<'a> {
+ server: Arc,
app_event_tx: AppEventSender,
app_event_rx: Receiver,
app_state: AppState<'a>,
@@ -64,6 +65,9 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec>,
enhanced_keys_supported: bool,
+
+ /// Controls the animation thread that sends CommitTick events.
+ commit_anim_running: Arc,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -83,6 +87,8 @@ impl App<'_> {
initial_images: Vec,
show_trust_screen: bool,
) -> Self {
+ let conversation_manager = Arc::new(ConversationManager::default());
+
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let pending_redraw = Arc::new(AtomicBool::new(false));
@@ -111,10 +117,8 @@ impl App<'_> {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Paste(pasted) => {
- // Many terminals convert newlines to \r when
- // pasting, e.g. [iTerm2][]. But [tui-textarea
- // expects \n][tui-textarea]. This seems like a bug
- // in tui-textarea IMO, but work around it for now.
+ // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
+ // but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
@@ -153,6 +157,7 @@ impl App<'_> {
} else {
let chat_widget = ChatWidget::new(
config.clone(),
+ conversation_manager.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
@@ -165,6 +170,7 @@ impl App<'_> {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
Self {
+ server: conversation_manager,
app_event_tx,
pending_history_lines: Vec::new(),
app_event_rx,
@@ -173,6 +179,7 @@ impl App<'_> {
file_search,
pending_redraw,
enhanced_keys_supported,
+ commit_anim_running: Arc::new(AtomicBool::new(false)),
}
}
@@ -189,7 +196,7 @@ impl App<'_> {
// redraw is already pending so we can return early.
if self
.pending_redraw
- .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
@@ -200,7 +207,7 @@ impl App<'_> {
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
- pending_redraw.store(false, Ordering::SeqCst);
+ pending_redraw.store(false, Ordering::Release);
});
}
@@ -221,6 +228,30 @@ impl App<'_> {
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
+ AppEvent::StartCommitAnimation => {
+ if self
+ .commit_anim_running
+ .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
+ .is_ok()
+ {
+ let tx = self.app_event_tx.clone();
+ let running = self.commit_anim_running.clone();
+ thread::spawn(move || {
+ while running.load(Ordering::Relaxed) {
+ thread::sleep(Duration::from_millis(50));
+ tx.send(AppEvent::CommitTick);
+ }
+ });
+ }
+ }
+ AppEvent::StopCommitAnimation => {
+ self.commit_anim_running.store(false, Ordering::Release);
+ }
+ AppEvent::CommitTick => {
+ if let AppState::Chat { widget } = &mut self.app_state {
+ widget.on_commit_tick();
+ }
+ }
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {
@@ -236,29 +267,17 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::ExitRequest);
}
},
- KeyEvent {
- code: KeyCode::Esc,
- kind: KeyEventKind::Press,
- ..
- } => match &mut self.app_state {
- AppState::Chat { widget } => {
- if !widget.on_esc() {
- self.dispatch_key_event(key_event);
- }
- }
- AppState::Onboarding { .. } => {
- self.dispatch_key_event(key_event);
- }
- },
KeyEvent {
code: KeyCode::Char('z'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
- if let AppState::Chat { widget } = &mut self.app_state {
- widget.on_ctrl_z();
+ #[cfg(unix)]
+ {
+ self.suspend(terminal)?;
}
+ // No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('d'),
@@ -289,7 +308,7 @@ impl App<'_> {
self.dispatch_key_event(key_event);
}
_ => {
- // Ignore Release key events for now.
+ // Ignore Release key events.
}
};
}
@@ -315,6 +334,7 @@ impl App<'_> {
// User accepted – switch to chat view.
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
+ self.server.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
@@ -366,6 +386,11 @@ impl App<'_> {
widget.add_diff_output(text);
}
}
+ SlashCommand::Mention => {
+ if let AppState::Chat { widget } = &mut self.app_state {
+ widget.insert_str("@");
+ }
+ }
SlashCommand::Status => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_status_output();
@@ -378,6 +403,7 @@ impl App<'_> {
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
+ use codex_core::protocol::EventMsg;
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
@@ -430,6 +456,7 @@ impl App<'_> {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
config,
+ self.server.clone(),
app_event_tx.clone(),
initial_prompt,
initial_images,
@@ -454,6 +481,23 @@ impl App<'_> {
Ok(())
}
+ #[cfg(unix)]
+ fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
+ tui::restore()?;
+ // SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
+ // current process group (pid 0) to trigger standard job-control
+ // suspension semantics. This FFI does not involve any raw pointers,
+ // is not called from a signal handler, and uses a constant signal.
+ // Errors from kill are acceptable (e.g., if already stopped) — the
+ // subsequent re-init path will still leave the terminal in a good state.
+ // We considered `nix`, but didn't think it was worth pulling in for this one call.
+ unsafe { libc::kill(0, libc::SIGTSTP) };
+ *terminal = tui::init(&self.config)?;
+ terminal.clear()?;
+ self.app_event_tx.send(AppEvent::RequestRedraw);
+ Ok(())
+ }
+
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
index 7f96fe1e47..a52f8baf39 100644
--- a/codex-rs/tui/src/app_event.rs
+++ b/codex-rs/tui/src/app_event.rs
@@ -7,6 +7,7 @@ use crate::app::ChatWidgetArgs;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
+#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
@@ -50,6 +51,10 @@ pub(crate) enum AppEvent {
InsertHistory(Vec>),
+ StartCommitAnimation,
+ StopCommitAnimation,
+ CommitTick,
+
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
OnboardingComplete(ChatWidgetArgs),
diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs
index f6c8c18c98..901bb41024 100644
--- a/codex-rs/tui/src/app_event_sender.rs
+++ b/codex-rs/tui/src/app_event_sender.rs
@@ -1,6 +1,7 @@
use std::sync::mpsc::Sender;
use crate::app_event::AppEvent;
+use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
@@ -15,6 +16,11 @@ impl AppEventSender {
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
+ // Record inbound events for high-fidelity session replay.
+ // Avoid double-logging Ops; those are logged at the point of submission.
+ if !matches!(event, AppEvent::CodexOp(_)) {
+ session_log::log_inbound_app_event(&event);
+ }
if let Err(e) = self.app_event_tx.send(event) {
tracing::error!("failed to send event: {e}");
}
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
index 8e9ff6d936..57bb0ad73a 100644
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
@@ -75,14 +75,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
- use std::path::PathBuf;
use std::sync::mpsc::channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
- cwd: PathBuf::from("/tmp"),
reason: None,
}
}
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 2743ada547..78506f572c 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -198,6 +198,12 @@ impl ChatComposer {
self.set_has_focus(has_focus);
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.textarea.insert_str(text);
+ self.sync_command_popup();
+ self.sync_file_search_popup();
+ }
+
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
@@ -698,14 +704,15 @@ impl WidgetRef for &ChatComposer {
let token_usage = &token_usage_info.total_token_usage;
hint.push(Span::from(" "));
hint.push(
- Span::from(format!("{} tokens used", token_usage.total_tokens))
+ Span::from(format!("{} tokens used", token_usage.blended_total()))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- - (last_token_usage.total_tokens as f32 / context_window as f32
+ - (last_token_usage.tokens_in_context_window() as f32
+ / context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
@@ -1077,6 +1084,46 @@ mod tests {
}
}
+ #[test]
+ fn slash_mention_dispatches_command_and_inserts_at() {
+ use crossterm::event::KeyCode;
+ use crossterm::event::KeyEvent;
+ use crossterm::event::KeyModifiers;
+ use std::sync::mpsc::TryRecvError;
+
+ let (tx, rx) = std::sync::mpsc::channel();
+ let sender = AppEventSender::new(tx);
+ let mut composer = ChatComposer::new(true, sender, false);
+
+ for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
+ let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
+ }
+
+ let (result, _needs_redraw) =
+ composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
+
+ match result {
+ InputResult::None => {}
+ InputResult::Submitted(text) => {
+ panic!("expected command dispatch, but composer submitted literal text: {text}")
+ }
+ }
+ assert!(composer.textarea.is_empty(), "composer should be cleared");
+
+ match rx.try_recv() {
+ Ok(AppEvent::DispatchCommand(cmd)) => {
+ assert_eq!(cmd.command(), "mention");
+ composer.insert_str("@");
+ }
+ Ok(_other) => panic!("unexpected app event"),
+ Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"),
+ Err(TryRecvError::Disconnected) => {
+ panic!("app event channel disconnected")
+ }
+ }
+ assert_eq!(composer.textarea.text(), "@");
+ }
+
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
diff --git a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
deleted file mode 100644
index 13f91acc5d..0000000000
--- a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-use ratatui::buffer::Buffer;
-use ratatui::layout::Rect;
-use ratatui::text::Line;
-use ratatui::widgets::Paragraph;
-use ratatui::widgets::WidgetRef;
-
-/// Minimal rendering-only widget for the transient ring rows.
-pub(crate) struct LiveRingWidget {
- max_rows: u16,
- rows: Vec>, // newest at the end
-}
-
-impl LiveRingWidget {
- pub fn new() -> Self {
- Self {
- max_rows: 3,
- rows: Vec::new(),
- }
- }
-
- pub fn set_max_rows(&mut self, n: u16) {
- self.max_rows = n.max(1);
- }
-
- pub fn set_rows(&mut self, rows: Vec>) {
- self.rows = rows;
- }
-
- pub fn desired_height(&self, _width: u16) -> u16 {
- let len = self.rows.len() as u16;
- len.min(self.max_rows)
- }
-}
-
-impl WidgetRef for LiveRingWidget {
- fn render_ref(&self, area: Rect, buf: &mut Buffer) {
- if area.height == 0 {
- return;
- }
- let visible = self.rows.len().saturating_sub(self.max_rows as usize);
- let slice = &self.rows[visible..];
- let para = Paragraph::new(slice.to_vec());
- para.render_ref(area, buf);
- }
-}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 0c8610470c..69f174f148 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -9,7 +9,6 @@ use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
-use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
mod approval_modal_view;
@@ -18,7 +17,6 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
-mod live_ring_widget;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
@@ -57,10 +55,6 @@ pub(crate) struct BottomPane<'a> {
/// not replace the composer; it augments it.
live_status: Option,
- /// Optional transient ring shown above the composer. This is a rendering-only
- /// container used during development before we wire it to ChatWidget events.
- live_ring: Option,
-
/// True if the active view is the StatusIndicatorView that replaces the
/// composer during a running task.
status_view_active: bool,
@@ -88,7 +82,6 @@ impl BottomPane<'_> {
is_task_running: false,
ctrl_c_quit_hint: false,
live_status: None,
- live_ring: None,
status_view_active: false,
}
}
@@ -99,26 +92,14 @@ impl BottomPane<'_> {
.as_ref()
.map(|s| s.desired_height(width))
.unwrap_or(0);
- let ring_h = self
- .live_ring
- .as_ref()
- .map(|r| r.desired_height(width))
- .unwrap_or(0);
let view_height = if let Some(view) = self.active_view.as_ref() {
- // Add a single blank spacer line between live ring and status view when active.
- let spacer = if self.live_ring.is_some() && self.status_view_active {
- 1
- } else {
- 0
- };
- spacer + view.desired_height(width)
+ view.desired_height(width)
} else {
self.composer.desired_height(width)
};
overlay_status_h
- .saturating_add(ring_h)
.saturating_add(view_height)
.saturating_add(Self::BOTTOM_PAD_LINES)
}
@@ -196,6 +177,11 @@ impl BottomPane<'_> {
}
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.composer.insert_str(text);
+ self.request_redraw();
+ }
+
/// Update the status indicator text. Prefer replacing the composer with
/// the StatusIndicatorView so the input pane shows a single-line status
/// like: `▌ Working waiting for model`.
@@ -352,43 +338,11 @@ impl BottomPane<'_> {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}
-
- /// Set the rows and cap for the transient live ring overlay.
- pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec>) {
- let mut w = live_ring_widget::LiveRingWidget::new();
- w.set_max_rows(max_rows);
- w.set_rows(rows);
- self.live_ring = Some(w);
- }
-
- pub(crate) fn clear_live_ring(&mut self) {
- self.live_ring = None;
- }
-
- // Removed restart_live_status_with_text – no longer used by the current streaming UI.
}
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut y_offset = 0u16;
- if let Some(ring) = &self.live_ring {
- let live_h = ring.desired_height(area.width).min(area.height);
- if live_h > 0 {
- let live_rect = Rect {
- x: area.x,
- y: area.y,
- width: area.width,
- height: live_h,
- };
- ring.render_ref(live_rect, buf);
- y_offset = live_h;
- }
- }
- // Spacer between live ring and status view when active
- if self.live_ring.is_some() && self.status_view_active && y_offset < area.height {
- // Leave one empty line
- y_offset = y_offset.saturating_add(1);
- }
if let Some(status) = &self.live_status {
let live_h = status
.desired_height(area.width)
@@ -438,15 +392,12 @@ mod tests {
use crate::app_event::AppEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
- use ratatui::text::Line;
- use std::path::PathBuf;
use std::sync::mpsc::channel;
fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
- cwd: PathBuf::from("."),
reason: None,
}
}
@@ -466,103 +417,7 @@ mod tests {
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
}
- #[test]
- fn live_ring_renders_above_composer() {
- let (tx_raw, _rx) = channel::();
- let tx = AppEventSender::new(tx_raw);
- let mut pane = BottomPane::new(BottomPaneParams {
- app_event_tx: tx,
- has_input_focus: true,
- enhanced_keys_supported: false,
- });
-
- // Provide 4 rows with max_rows=3; only the last 3 should be visible.
- pane.set_live_ring_rows(
- 3,
- vec![
- Line::from("one".to_string()),
- Line::from("two".to_string()),
- Line::from("three".to_string()),
- Line::from("four".to_string()),
- ],
- );
-
- let area = Rect::new(0, 0, 10, 5);
- let mut buf = Buffer::empty(area);
- (&pane).render_ref(area, &mut buf);
-
- // Extract the first 3 rows and assert they contain the last three lines.
- let mut lines: Vec = Vec::new();
- for y in 0..3 {
- let mut s = String::new();
- for x in 0..area.width {
- s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
- }
- lines.push(s.trim_end().to_string());
- }
- assert_eq!(lines, vec!["two", "three", "four"]);
- }
-
- #[test]
- fn status_indicator_visible_with_live_ring() {
- let (tx_raw, _rx) = channel::();
- let tx = AppEventSender::new(tx_raw);
- let mut pane = BottomPane::new(BottomPaneParams {
- app_event_tx: tx,
- has_input_focus: true,
- enhanced_keys_supported: false,
- });
-
- // Simulate task running which replaces composer with the status indicator.
- pane.set_task_running(true);
- pane.update_status_text("waiting for model".to_string());
-
- // Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the
- // status indicator remains visible below them.
- pane.set_live_ring_rows(
- 2,
- vec![
- Line::from("cot1".to_string()),
- Line::from("cot2".to_string()),
- ],
- );
-
- // Allow some frames so the dot animation is present.
- std::thread::sleep(std::time::Duration::from_millis(120));
-
- // Height should include both ring rows, 1 spacer, and the 1-line status.
- let area = Rect::new(0, 0, 30, 4);
- let mut buf = Buffer::empty(area);
- (&pane).render_ref(area, &mut buf);
-
- // Top two rows are the live ring.
- let mut r0 = String::new();
- let mut r1 = String::new();
- for x in 0..area.width {
- r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
- r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
- }
- assert!(r0.contains("cot1"), "expected first live row: {r0:?}");
- assert!(r1.contains("cot2"), "expected second live row: {r1:?}");
-
- // Row 2 is the spacer (blank)
- let mut r2 = String::new();
- for x in 0..area.width {
- r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
- }
- assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
-
- // Bottom row is the status line; it should contain the left bar and "Working".
- let mut r3 = String::new();
- for x in 0..area.width {
- r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
- }
- assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
- assert!(
- r3.contains("Working"),
- "expected Working header in status line: {r3:?}"
- );
- }
+ // live ring removed; related tests deleted.
#[test]
fn overlay_not_shown_above_approval_modal() {
diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
index a944271e45..cad4f0f272 100644
--- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
+++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
@@ -1,7 +1,10 @@
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
+use crate::bottom_pane::BottomPane;
use crate::status_indicator_widget::StatusIndicatorWidget;
use super::BottomPaneView;
@@ -40,4 +43,10 @@ impl BottomPaneView<'_> for StatusIndicatorView {
fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
self.view.render_ref(area, buf);
}
+
+ fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
+ if key_event.code == KeyCode::Esc {
+ self.view.interrupt();
+ }
+ }
}
diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs
index c45c86e5af..dabc276a37 100644
--- a/codex-rs/tui/src/bottom_pane/textarea.rs
+++ b/codex-rs/tui/src/bottom_pane/textarea.rs
@@ -109,7 +109,7 @@ impl TextArea {
self.wrapped_lines(width).len() as u16
}
- #[allow(dead_code)]
+ #[cfg_attr(not(test), allow(dead_code))]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, &TextAreaState::default())
}
@@ -295,6 +295,20 @@ impl TextArea {
} => {
self.move_cursor_right();
}
+ KeyEvent {
+ code: KeyCode::Char('b'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => {
+ self.move_cursor_left();
+ }
+ KeyEvent {
+ code: KeyCode::Char('f'),
+ modifiers: KeyModifiers::CONTROL,
+ ..
+ } => {
+ self.move_cursor_right();
+ }
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
@@ -359,8 +373,9 @@ impl TextArea {
} => {
self.move_cursor_to_end_of_line(true);
}
- o => {
- tracing::debug!("Unhandled key event in TextArea: {:?}", o);
+ _o => {
+ #[cfg(feature = "debug-logs")]
+ tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
}
}
}
@@ -907,6 +922,22 @@ mod tests {
assert_eq!(t.cursor(), t.text().len());
}
+ #[test]
+ fn control_b_and_f_move_cursor() {
+ use crossterm::event::KeyCode;
+ use crossterm::event::KeyEvent;
+ use crossterm::event::KeyModifiers;
+
+ let mut t = ta_with("abcd");
+ t.set_cursor(1);
+
+ t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
+ assert_eq!(t.cursor(), 2);
+
+ t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
+ assert_eq!(t.cursor(), 1);
+ }
+
#[test]
fn cursor_vertical_movement_across_lines_and_bounds() {
let mut t = ta_with("short\nloooooooooong\nmid");
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 344f025842..517a9a35f5 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -2,9 +2,8 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
-use codex_core::codex_wrapper::CodexConversation;
-use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
+use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -36,8 +35,7 @@ use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tokio::sync::mpsc::UnboundedSender;
-use tokio::sync::mpsc::unbounded_channel;
-use tracing::info;
+use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -45,40 +43,47 @@ use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
+use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
-use crate::live_wrap::RowBuilder;
+// streaming internals are provided by crate::streaming and crate::markdown_stream
use crate::user_approval_widget::ApprovalRequest;
+mod interrupts;
+use self::interrupts::InterruptManager;
+mod agent;
+use self::agent::spawn_agent;
+use crate::streaming::controller::AppEventHistorySink;
+use crate::streaming::controller::StreamController;
+use codex_core::ConversationManager;
use codex_file_search::FileMatch;
-use ratatui::style::Stylize;
+// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec,
- #[allow(dead_code)]
- cwd: PathBuf,
+ parsed_cmd: Vec,
}
pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender,
bottom_pane: BottomPane<'a>,
- active_history_cell: Option,
+ active_exec_cell: Option,
config: Config,
initial_user_message: Option,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
- reasoning_buffer: String,
- content_buffer: String,
- // Buffer for streaming assistant answer text; we do not surface partial
- // We wait for the final AgentMessage event and then emit the full text
- // at once into scrollback so the history contains a single message.
- answer_buffer: String,
+ // Stream lifecycle controller
+ stream: StreamController,
+ // Track the most recently active stream kind in the current turn
+ last_stream_kind: Option,
running_commands: HashMap,
- live_builder: RowBuilder,
- current_stream: Option,
- stream_header_emitted: bool,
- live_max_rows: u16,
+ pending_exec_completions: Vec<(Vec, Vec, CommandOutput)>,
+ task_complete_pending: bool,
+ // Queue of interruptive UI events deferred during an active write cycle
+ interrupts: InterruptManager,
+ // Whether a redraw is needed after handling the current event
+ needs_redraw: bool,
}
struct UserMessage {
@@ -86,11 +91,7 @@ struct UserMessage {
image_paths: Vec,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum StreamKind {
- Answer,
- Reasoning,
-}
+use crate::streaming::StreamKind;
impl From for UserMessage {
fn from(text: String) -> Self {
@@ -110,26 +111,385 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio
}
impl ChatWidget<'_> {
+ #[inline]
+ fn mark_needs_redraw(&mut self) {
+ self.needs_redraw = true;
+ }
+ fn flush_answer_stream_with_separator(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let _ = self.stream.finalize(StreamKind::Answer, true, &sink);
+ }
+ // --- Small event handlers ---
+ fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
+ self.bottom_pane
+ .set_history_metadata(event.history_log_id, event.history_entry_count);
+ self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
+ if let Some(user_message) = self.initial_user_message.take() {
+ self.submit_user_message(user_message);
+ }
+ self.mark_needs_redraw();
+ }
+
+ fn on_agent_message(&mut self, message: String) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.apply_final_answer(&message, &sink);
+ self.handle_if_stream_finished(finished);
+ self.mark_needs_redraw();
+ }
+
+ fn on_agent_message_delta(&mut self, delta: String) {
+ self.handle_streaming_delta(StreamKind::Answer, delta);
+ }
+
+ fn on_agent_reasoning_delta(&mut self, delta: String) {
+ self.handle_streaming_delta(StreamKind::Reasoning, delta);
+ }
+
+ fn on_agent_reasoning_final(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.finalize(StreamKind::Reasoning, false, &sink);
+ self.handle_if_stream_finished(finished);
+ self.mark_needs_redraw();
+ }
+
+ fn on_reasoning_section_break(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ self.stream.insert_reasoning_section_break(&sink);
+ }
+
+ // Raw reasoning uses the same flow as summarized reasoning
+
+ fn on_task_started(&mut self) {
+ self.bottom_pane.clear_ctrl_c_quit_hint();
+ self.bottom_pane.set_task_running(true);
+ self.set_waiting_for_model_status();
+ self.stream.reset_headers_for_new_turn();
+ self.last_stream_kind = None;
+ self.mark_needs_redraw();
+ }
+
+ fn on_task_complete(&mut self) {
+ // If a stream is currently active, finalize only that stream to flush any tail
+ // without emitting stray headers for other streams.
+ if self.stream.is_write_cycle_active() {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ if let Some(kind) = self.last_stream_kind {
+ let _ = self.stream.finalize(kind, true, &sink);
+ }
+ }
+ // Mark task stopped and request redraw now that all content is in history.
+ self.bottom_pane.set_task_running(false);
+ self.mark_needs_redraw();
+ }
+
+ fn on_token_count(&mut self, token_usage: TokenUsage) {
+ self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
+ self.last_token_usage = token_usage;
+ self.bottom_pane.set_token_usage(
+ self.total_token_usage.clone(),
+ self.last_token_usage.clone(),
+ self.config.model_context_window,
+ );
+ }
+
+ fn on_error(&mut self, message: String) {
+ self.add_to_history(HistoryCell::new_error_event(message));
+ self.bottom_pane.set_task_running(false);
+ self.stream.clear_all();
+ self.mark_needs_redraw();
+ }
+
+ fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
+ self.add_to_history(HistoryCell::new_plan_update(update));
+ }
+
+ fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ let id2 = id.clone();
+ let ev2 = ev.clone();
+ self.defer_or_handle(
+ |q| q.push_exec_approval(id, ev),
+ |s| s.handle_exec_approval_now(id2, ev2),
+ );
+ }
+
+ fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) {
+ let id2 = id.clone();
+ let ev2 = ev.clone();
+ self.defer_or_handle(
+ |q| q.push_apply_patch_approval(id, ev),
+ |s| s.handle_apply_patch_approval_now(id2, ev2),
+ );
+ }
+
+ fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
+ self.flush_answer_stream_with_separator();
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
+ }
+
+ fn on_exec_command_output_delta(
+ &mut self,
+ _ev: codex_core::protocol::ExecCommandOutputDeltaEvent,
+ ) {
+ // TODO: Handle streaming exec output if/when implemented
+ }
+
+ fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
+ self.add_to_history(HistoryCell::new_patch_event(
+ PatchEventType::ApplyBegin {
+ auto_approved: event.auto_approved,
+ },
+ event.changes,
+ ));
+ }
+
+ fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
+ let ev2 = event.clone();
+ self.defer_or_handle(
+ |q| q.push_patch_end(event),
+ |s| s.handle_patch_apply_end_now(ev2),
+ );
+ }
+
+ fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2));
+ }
+
+ fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2));
+ }
+
+ fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) {
+ let ev2 = ev.clone();
+ self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2));
+ }
+
+ fn on_get_history_entry_response(
+ &mut self,
+ event: codex_core::protocol::GetHistoryEntryResponseEvent,
+ ) {
+ let codex_core::protocol::GetHistoryEntryResponseEvent {
+ offset,
+ log_id,
+ entry,
+ } = event;
+ self.bottom_pane
+ .on_history_entry_response(log_id, offset, entry.map(|e| e.text));
+ }
+
+ fn on_shutdown_complete(&mut self) {
+ self.app_event_tx.send(AppEvent::ExitRequest);
+ }
+
+ fn on_turn_diff(&mut self, unified_diff: String) {
+ debug!("TurnDiffEvent: {unified_diff}");
+ }
+
+ fn on_background_event(&mut self, message: String) {
+ debug!("BackgroundEvent: {message}");
+ }
+ /// Periodic tick to commit at most one queued line to history with a small delay,
+ /// animating the output.
+ pub(crate) fn on_commit_tick(&mut self) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ let finished = self.stream.on_commit_tick(&sink);
+ self.handle_if_stream_finished(finished);
+ }
+ fn is_write_cycle_active(&self) -> bool {
+ self.stream.is_write_cycle_active()
+ }
+
+ fn flush_interrupt_queue(&mut self) {
+ let mut mgr = std::mem::take(&mut self.interrupts);
+ mgr.flush_all(self);
+ self.interrupts = mgr;
+ }
+
+ #[inline]
+ fn defer_or_handle(
+ &mut self,
+ push: impl FnOnce(&mut InterruptManager),
+ handle: impl FnOnce(&mut Self),
+ ) {
+ // Preserve deterministic FIFO across queued interrupts: once anything
+ // is queued due to an active write cycle, continue queueing until the
+ // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin).
+ if self.is_write_cycle_active() || !self.interrupts.is_empty() {
+ push(&mut self.interrupts);
+ } else {
+ handle(self);
+ }
+ }
+
+ #[inline]
+ fn handle_if_stream_finished(&mut self, finished: bool) {
+ if finished {
+ if self.task_complete_pending {
+ self.bottom_pane.set_task_running(false);
+ self.task_complete_pending = false;
+ }
+ self.flush_interrupt_queue();
+ }
+ }
+
+ #[inline]
+ fn set_waiting_for_model_status(&mut self) {
+ self.bottom_pane
+ .update_status_text("waiting for model".to_string());
+ }
+
+ #[inline]
+ fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
+ let sink = AppEventHistorySink(self.app_event_tx.clone());
+ self.set_waiting_for_model_status();
+ self.stream.begin(kind, &sink);
+ self.last_stream_kind = Some(kind);
+ self.stream.push_and_maybe_commit(&delta, &sink);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
+ let running = self.running_commands.remove(&ev.call_id);
+ let (command, parsed) = match running {
+ Some(rc) => (rc.command, rc.parsed_cmd),
+ None => (vec![ev.call_id.clone()], Vec::new()),
+ };
+ self.pending_exec_completions.push((
+ command,
+ parsed,
+ CommandOutput {
+ exit_code: ev.exit_code,
+ stdout: ev.stdout.clone(),
+ stderr: ev.stderr.clone(),
+ },
+ ));
+
+ if self.running_commands.is_empty() {
+ self.active_exec_cell = None;
+ let pending = std::mem::take(&mut self.pending_exec_completions);
+ for (command, parsed, output) in pending {
+ self.add_to_history(HistoryCell::new_completed_exec_command(
+ command, parsed, output,
+ ));
+ }
+ }
+ }
+
+ pub(crate) fn handle_patch_apply_end_now(
+ &mut self,
+ event: codex_core::protocol::PatchApplyEndEvent,
+ ) {
+ if event.success {
+ self.add_to_history(HistoryCell::new_patch_apply_success(event.stdout));
+ } else {
+ self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
+ }
+ }
+
+ pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ self.flush_answer_stream_with_separator();
+ // Log a background summary immediately so the history is chronological.
+ let cmdline = strip_bash_lc_and_escape(&ev.command);
+ let text = format!(
+ "command requires approval:\n$ {cmdline}{reason}",
+ reason = ev
+ .reason
+ .as_ref()
+ .map(|r| format!("\n{r}"))
+ .unwrap_or_default()
+ );
+ self.add_to_history(HistoryCell::new_background_event(text));
+
+ let request = ApprovalRequest::Exec {
+ id,
+ command: ev.command,
+ reason: ev.reason,
+ };
+ self.bottom_pane.push_approval_request(request);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_apply_patch_approval_now(
+ &mut self,
+ id: String,
+ ev: ApplyPatchApprovalRequestEvent,
+ ) {
+ self.flush_answer_stream_with_separator();
+ self.add_to_history(HistoryCell::new_patch_event(
+ PatchEventType::ApprovalRequest,
+ ev.changes.clone(),
+ ));
+
+ let request = ApprovalRequest::ApplyPatch {
+ id,
+ reason: ev.reason,
+ grant_root: ev.grant_root,
+ };
+ self.bottom_pane.push_approval_request(request);
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
+ // Ensure the status indicator is visible while the command runs.
+ self.bottom_pane
+ .update_status_text("running command".to_string());
+ self.running_commands.insert(
+ ev.call_id.clone(),
+ RunningCommand {
+ command: ev.command.clone(),
+ parsed_cmd: ev.parsed_cmd.clone(),
+ },
+ );
+ // Accumulate parsed commands into a single active Exec cell so they stack
+ match self.active_exec_cell.as_mut() {
+ Some(HistoryCell::Exec(exec)) => {
+ exec.parsed.extend(ev.parsed_cmd);
+ }
+ _ => {
+ self.active_exec_cell = Some(HistoryCell::new_active_exec_command(
+ ev.command,
+ ev.parsed_cmd,
+ ));
+ }
+ }
+
+ // Request a redraw so the working header and command list are visible immediately.
+ self.mark_needs_redraw();
+ }
+
+ pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
+ self.flush_answer_stream_with_separator();
+ self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation));
+ }
+ pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
+ self.flush_answer_stream_with_separator();
+ self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
+ 80,
+ ev.invocation,
+ ev.duration,
+ ev.result
+ .as_ref()
+ .map(|r| !r.is_error.unwrap_or(false))
+ .unwrap_or(false),
+ ev.result,
+ ));
+ }
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
- self.active_history_cell = None;
+ self.active_exec_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.current_stream = None;
- self.stream_header_emitted = false;
- self.answer_buffer.clear();
- self.reasoning_buffer.clear();
- self.content_buffer.clear();
+ self.stream.clear_all();
self.request_redraw();
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Max(
- self.active_history_cell
+ self.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
@@ -137,68 +497,16 @@ impl ChatWidget<'_> {
])
.areas(area)
}
- fn emit_stream_header(&mut self, kind: StreamKind) {
- use ratatui::text::Line as RLine;
- if self.stream_header_emitted {
- return;
- }
- let header = match kind {
- StreamKind::Reasoning => RLine::from("thinking".magenta().italic()),
- StreamKind::Answer => RLine::from("codex".magenta().bold()),
- };
- self.app_event_tx
- .send(AppEvent::InsertHistory(vec![header]));
- self.stream_header_emitted = true;
- }
- fn finalize_active_stream(&mut self) {
- if let Some(kind) = self.current_stream {
- self.finalize_stream(kind);
- }
- }
+
pub(crate) fn new(
config: Config,
+ conversation_manager: Arc,
app_event_tx: AppEventSender,
initial_prompt: Option,
initial_images: Vec,
enhanced_keys_supported: bool,
) -> Self {
- let (codex_op_tx, mut codex_op_rx) = unbounded_channel::();
-
- let app_event_tx_clone = app_event_tx.clone();
- // Create the Codex asynchronously so the UI loads as quickly as possible.
- let config_for_agent_loop = config.clone();
- tokio::spawn(async move {
- let CodexConversation {
- codex,
- session_configured,
- ..
- } = match init_codex(config_for_agent_loop).await {
- Ok(vals) => vals,
- Err(e) => {
- // TODO: surface this error to the user.
- tracing::error!("failed to initialize codex: {e}");
- return;
- }
- };
-
- // Forward the captured `SessionInitialized` event that was consumed
- // inside `init_codex()` so it can be rendered in the UI.
- app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
- let codex = Arc::new(codex);
- let codex_clone = codex.clone();
- tokio::spawn(async move {
- while let Some(op) = codex_op_rx.recv().await {
- let id = codex_clone.submit(op).await;
- if let Err(e) = id {
- tracing::error!("failed to submit op: {e}");
- }
- }
- });
-
- while let Ok(event) = codex.next_event().await {
- app_event_tx_clone.send(AppEvent::CodexEvent(event));
- }
- });
+ let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
Self {
app_event_tx: app_event_tx.clone(),
@@ -208,29 +516,28 @@ impl ChatWidget<'_> {
has_input_focus: true,
enhanced_keys_supported,
}),
- active_history_cell: None,
- config,
+ active_exec_cell: None,
+ config: config.clone(),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
- reasoning_buffer: String::new(),
- content_buffer: String::new(),
- answer_buffer: String::new(),
+ stream: StreamController::new(config),
+ last_stream_kind: None,
running_commands: HashMap::new(),
- live_builder: RowBuilder::new(80),
- current_stream: None,
- stream_header_emitted: false,
- live_max_rows: 3,
+ pending_exec_completions: Vec::new(),
+ task_complete_pending: false,
+ interrupts: InterruptManager::new(),
+ needs_redraw: false,
}
}
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
- .active_history_cell
+ .active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
@@ -252,7 +559,15 @@ impl ChatWidget<'_> {
self.bottom_pane.handle_paste(text);
}
+ fn flush_active_exec_cell(&mut self) {
+ if let Some(active) = self.active_exec_cell.take() {
+ self.app_event_tx
+ .send(AppEvent::InsertHistory(active.plain_lines()));
+ }
+ }
+
fn add_to_history(&mut self, cell: HistoryCell) {
+ self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
}
@@ -288,256 +603,67 @@ impl ChatWidget<'_> {
});
}
- // Only show text portion in conversation history for now.
+ // Only show the text portion in conversation history.
if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
+ // Reset redraw flag for this dispatch
+ self.needs_redraw = false;
let Event { id, msg } = event;
+
match msg {
- EventMsg::SessionConfigured(event) => {
- self.bottom_pane
- .set_history_metadata(event.history_log_id, event.history_entry_count);
- // Record session information at the top of the conversation.
- self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
-
- if let Some(user_message) = self.initial_user_message.take() {
- // If the user provided an initial message, add it to the
- // conversation history.
- self.submit_user_message(user_message);
- }
-
- self.request_redraw();
- }
- EventMsg::AgentMessage(AgentMessageEvent { message: _ }) => {
- // Final assistant answer: commit all remaining rows and close with
- // a blank line. Use the final text if provided, otherwise rely on
- // streamed deltas already in the builder.
- self.finalize_stream(StreamKind::Answer);
- self.request_redraw();
+ EventMsg::AgentMessageDelta(_)
+ | EventMsg::AgentReasoningDelta(_)
+ | EventMsg::ExecCommandOutputDelta(_) => {}
+ _ => {
+ tracing::info!("handle_codex_event: {:?}", msg);
}
+ }
+
+ match msg {
+ EventMsg::SessionConfigured(e) => self.on_session_configured(e),
+ EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message),
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
- self.begin_stream(StreamKind::Answer);
- self.answer_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
+ self.on_agent_message_delta(delta)
}
- EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
- // Stream CoT into the live pane; keep input visible and commit
- // overflow rows incrementally to scrollback.
- self.begin_stream(StreamKind::Reasoning);
- self.reasoning_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
- }
- EventMsg::AgentReasoning(AgentReasoningEvent { text: _ }) => {
- // Final reasoning: commit remaining rows and close with a blank.
- self.finalize_stream(StreamKind::Reasoning);
- self.request_redraw();
- }
- EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
+ EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta })
+ | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
- }) => {
- // Treat raw reasoning content the same as summarized reasoning for UI flow.
- self.begin_stream(StreamKind::Reasoning);
- self.reasoning_buffer.push_str(&delta);
- self.stream_push_and_maybe_commit(&delta);
- self.request_redraw();
- }
- EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text: _ }) => {
- // Finalize the raw reasoning stream just like the summarized reasoning event.
- self.finalize_stream(StreamKind::Reasoning);
- self.request_redraw();
- }
- EventMsg::TaskStarted => {
- self.bottom_pane.clear_ctrl_c_quit_hint();
- self.bottom_pane.set_task_running(true);
- // Replace composer with single-line spinner while waiting.
- self.bottom_pane
- .update_status_text("waiting for model".to_string());
- self.request_redraw();
- }
- EventMsg::TaskComplete(TaskCompleteEvent {
- last_agent_message: _,
- }) => {
- self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.request_redraw();
- }
- EventMsg::TokenCount(token_usage) => {
- self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
- self.last_token_usage = token_usage;
- self.bottom_pane.set_token_usage(
- self.total_token_usage.clone(),
- self.last_token_usage.clone(),
- self.config.model_context_window,
- );
- }
- EventMsg::Error(ErrorEvent { message }) => {
- self.add_to_history(HistoryCell::new_error_event(message.clone()));
- self.bottom_pane.set_task_running(false);
- self.bottom_pane.clear_live_ring();
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.current_stream = None;
- self.stream_header_emitted = false;
- self.answer_buffer.clear();
- self.reasoning_buffer.clear();
- self.content_buffer.clear();
- self.request_redraw();
- }
- EventMsg::PlanUpdate(update) => {
- // Commit plan updates directly to history (no status-line preview).
- self.add_to_history(HistoryCell::new_plan_update(update));
- }
- EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
- call_id: _,
- command,
- cwd,
- reason,
- }) => {
- self.finalize_active_stream();
- let request = ApprovalRequest::Exec {
- id,
- command,
- cwd,
- reason,
- };
- self.bottom_pane.push_approval_request(request);
- self.request_redraw();
- }
- EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
- call_id: _,
- changes,
- reason,
- grant_root,
- }) => {
- self.finalize_active_stream();
- // ------------------------------------------------------------------
- // Before we even prompt the user for approval we surface the patch
- // summary in the main conversation so that the dialog appears in a
- // sensible chronological order:
- // (1) codex → proposes patch (HistoryCell::PendingPatch)
- // (2) UI → asks for approval (BottomPane)
- // This mirrors how command execution is shown (command begins →
- // approval dialog) and avoids surprising the user with a modal
- // prompt before they have seen *what* is being requested.
- // ------------------------------------------------------------------
- self.add_to_history(HistoryCell::new_patch_event(
- PatchEventType::ApprovalRequest,
- changes,
- ));
-
- // Now surface the approval request in the BottomPane as before.
- let request = ApprovalRequest::ApplyPatch {
- id,
- reason,
- grant_root,
- };
- self.bottom_pane.push_approval_request(request);
- self.request_redraw();
- }
- EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
- call_id,
- command,
- cwd,
- }) => {
- self.finalize_active_stream();
- // Ensure the status indicator is visible while the command runs.
- self.bottom_pane
- .update_status_text("running command".to_string());
- self.running_commands.insert(
- call_id,
- RunningCommand {
- command: command.clone(),
- cwd: cwd.clone(),
- },
- );
- self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
- }
- EventMsg::ExecCommandOutputDelta(_) => {
- // TODO
- }
- EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
- call_id: _,
- auto_approved,
- changes,
- }) => {
- self.add_to_history(HistoryCell::new_patch_event(
- PatchEventType::ApplyBegin { auto_approved },
- changes,
- ));
- }
- EventMsg::PatchApplyEnd(event) => {
- if !event.success {
- self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
- }
- }
- EventMsg::ExecCommandEnd(ExecCommandEndEvent {
- call_id,
- exit_code,
- duration: _,
- stdout,
- stderr,
- }) => {
- // Compute summary before moving stdout into the history cell.
- let cmd = self.running_commands.remove(&call_id);
- self.active_history_cell = None;
- self.add_to_history(HistoryCell::new_completed_exec_command(
- cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
- CommandOutput {
- exit_code,
- stdout,
- stderr,
- },
- ));
- }
- EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
- call_id: _,
- invocation,
- }) => {
- self.finalize_active_stream();
- self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
- }
- EventMsg::McpToolCallEnd(McpToolCallEndEvent {
- call_id: _,
- duration,
- invocation,
- result,
- }) => {
- self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
- 80,
- invocation,
- duration,
- result
- .as_ref()
- .map(|r| r.is_error.unwrap_or(false))
- .unwrap_or(false),
- result,
- ));
- }
- EventMsg::GetHistoryEntryResponse(event) => {
- let codex_core::protocol::GetHistoryEntryResponseEvent {
- offset,
- log_id,
- entry,
- } = event;
-
- // Inform bottom pane / composer.
- self.bottom_pane
- .on_history_entry_response(log_id, offset, entry.map(|e| e.text));
- }
- EventMsg::ShutdownComplete => {
- self.app_event_tx.send(AppEvent::ExitRequest);
- }
- EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
- info!("TurnDiffEvent: {unified_diff}");
+ }) => self.on_agent_reasoning_delta(delta),
+ EventMsg::AgentReasoning(AgentReasoningEvent { .. })
+ | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
+ self.on_agent_reasoning_final()
}
+ EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
+ EventMsg::TaskStarted => self.on_task_started(),
+ EventMsg::TaskComplete(TaskCompleteEvent { .. }) => self.on_task_complete(),
+ EventMsg::TokenCount(token_usage) => self.on_token_count(token_usage),
+ EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
+ EventMsg::PlanUpdate(update) => self.on_plan_update(update),
+ EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev),
+ EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev),
+ EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
+ EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
+ EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
+ EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
+ EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
+ EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
+ EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
+ EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
+ EventMsg::ShutdownComplete => self.on_shutdown_complete(),
+ EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
- info!("BackgroundEvent: {message}");
+ self.on_background_event(message)
}
}
+ // Coalesce redraws: issue at most one after handling the event
+ if self.needs_redraw {
+ self.request_redraw();
+ self.needs_redraw = false;
+ }
}
/// Update the live log preview while a task is running.
@@ -571,14 +697,6 @@ impl ChatWidget<'_> {
self.bottom_pane.on_file_search_result(query, matches);
}
- pub(crate) fn on_esc(&mut self) -> bool {
- if self.bottom_pane.is_task_running() {
- self.interrupt_running_task();
- return true;
- }
- false
- }
-
/// Handle Ctrl-C key press.
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
@@ -599,16 +717,17 @@ impl ChatWidget<'_> {
}
}
- pub(crate) fn on_ctrl_z(&mut self) {
- self.interrupt_running_task();
- }
-
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
}
+ pub(crate) fn insert_str(&mut self, text: &str) {
+ self.bottom_pane.insert_str(text);
+ }
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
+ // Record outbound operation for session replay fidelity.
+ crate::session_log::log_outbound_op(&op);
if let Err(e) = self.codex_op_tx.send(op) {
tracing::error!("failed to submit op: {e}");
}
@@ -643,109 +762,11 @@ impl ChatWidget<'_> {
}
}
-impl ChatWidget<'_> {
- fn begin_stream(&mut self, kind: StreamKind) {
- if let Some(current) = self.current_stream {
- if current != kind {
- self.finalize_stream(current);
- }
- }
-
- if self.current_stream != Some(kind) {
- self.current_stream = Some(kind);
- self.stream_header_emitted = false;
- // Clear any previous live content; we're starting a new stream.
- self.live_builder = RowBuilder::new(self.live_builder.width());
- // Ensure the waiting status is visible (composer replaced).
- self.bottom_pane
- .update_status_text("waiting for model".to_string());
- self.emit_stream_header(kind);
- }
- }
-
- fn stream_push_and_maybe_commit(&mut self, delta: &str) {
- self.live_builder.push_fragment(delta);
-
- // Commit overflow rows (small batches) while keeping the last N rows visible.
- let drained = self
- .live_builder
- .drain_commit_ready(self.live_max_rows as usize);
- if !drained.is_empty() {
- let mut lines: Vec> = Vec::new();
- if !self.stream_header_emitted {
- match self.current_stream {
- Some(StreamKind::Reasoning) => {
- lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
- }
- Some(StreamKind::Answer) => {
- lines.push(ratatui::text::Line::from("codex".magenta().bold()));
- }
- None => {}
- }
- self.stream_header_emitted = true;
- }
- for r in drained {
- lines.push(ratatui::text::Line::from(r.text));
- }
- self.app_event_tx.send(AppEvent::InsertHistory(lines));
- }
-
- // Update the live ring overlay lines (text-only, newest at bottom).
- let rows = self
- .live_builder
- .display_rows()
- .into_iter()
- .map(|r| ratatui::text::Line::from(r.text))
- .collect::>();
- self.bottom_pane
- .set_live_ring_rows(self.live_max_rows, rows);
- }
-
- fn finalize_stream(&mut self, kind: StreamKind) {
- if self.current_stream != Some(kind) {
- // Nothing to do; either already finalized or not the active stream.
- return;
- }
- // Flush any partial line as a full row, then drain all remaining rows.
- self.live_builder.end_line();
- let remaining = self.live_builder.drain_rows();
- // TODO: Re-add markdown rendering for assistant answers and reasoning.
- // When finalizing, pass the accumulated text through `markdown::append_markdown`
- // to build styled `Line<'static>` entries instead of raw plain text lines.
- if !remaining.is_empty() || !self.stream_header_emitted {
- let mut lines: Vec> = Vec::new();
- if !self.stream_header_emitted {
- match kind {
- StreamKind::Reasoning => {
- lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
- }
- StreamKind::Answer => {
- lines.push(ratatui::text::Line::from("codex".magenta().bold()));
- }
- }
- self.stream_header_emitted = true;
- }
- for r in remaining {
- lines.push(ratatui::text::Line::from(r.text));
- }
- // Close the block with a blank line for readability.
- lines.push(ratatui::text::Line::from(""));
- self.app_event_tx.send(AppEvent::InsertHistory(lines));
- }
-
- // Clear the live overlay and reset state for the next stream.
- self.live_builder = RowBuilder::new(self.live_builder.width());
- self.bottom_pane.clear_live_ring();
- self.current_stream = None;
- self.stream_header_emitted = false;
- }
-}
-
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
- if let Some(cell) = &self.active_history_cell {
+ if let Some(cell) = &self.active_exec_cell {
cell.render_ref(active_cell_area, buf);
}
}
@@ -778,3 +799,6 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
+
+#[cfg(test)]
+mod tests;
diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs
new file mode 100644
index 0000000000..eb96864f0d
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/agent.rs
@@ -0,0 +1,61 @@
+use std::sync::Arc;
+
+use codex_core::ConversationManager;
+use codex_core::NewConversation;
+use codex_core::config::Config;
+use codex_core::protocol::Op;
+use tokio::sync::mpsc::UnboundedSender;
+use tokio::sync::mpsc::unbounded_channel;
+
+use crate::app_event::AppEvent;
+use crate::app_event_sender::AppEventSender;
+
+/// Spawn the agent bootstrapper and op forwarding loop, returning the
+/// `UnboundedSender` used by the UI to submit operations.
+pub(crate) fn spawn_agent(
+ config: Config,
+ app_event_tx: AppEventSender,
+ server: Arc,
+) -> UnboundedSender {
+ let (codex_op_tx, mut codex_op_rx) = unbounded_channel::();
+
+ let app_event_tx_clone = app_event_tx.clone();
+ tokio::spawn(async move {
+ let NewConversation {
+ conversation_id: _,
+ conversation,
+ session_configured,
+ } = match server.new_conversation(config).await {
+ Ok(v) => v,
+ Err(e) => {
+ // TODO: surface this error to the user.
+ tracing::error!("failed to initialize codex: {e}");
+ return;
+ }
+ };
+
+ // Forward the captured `SessionConfigured` event so it can be rendered in the UI.
+ let ev = codex_core::protocol::Event {
+ // The `id` does not matter for rendering, so we can use a fake value.
+ id: "".to_string(),
+ msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
+ };
+ app_event_tx_clone.send(AppEvent::CodexEvent(ev));
+
+ let conversation_clone = conversation.clone();
+ tokio::spawn(async move {
+ while let Some(op) = codex_op_rx.recv().await {
+ let id = conversation_clone.submit(op).await;
+ if let Err(e) = id {
+ tracing::error!("failed to submit op: {e}");
+ }
+ }
+ });
+
+ while let Ok(event) = conversation.next_event().await {
+ app_event_tx_clone.send(AppEvent::CodexEvent(event));
+ }
+ });
+
+ codex_op_tx
+}
diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs
new file mode 100644
index 0000000000..40fecb72f6
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/interrupts.rs
@@ -0,0 +1,89 @@
+use std::collections::VecDeque;
+
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+use codex_core::protocol::ExecApprovalRequestEvent;
+use codex_core::protocol::ExecCommandBeginEvent;
+use codex_core::protocol::ExecCommandEndEvent;
+use codex_core::protocol::McpToolCallBeginEvent;
+use codex_core::protocol::McpToolCallEndEvent;
+use codex_core::protocol::PatchApplyEndEvent;
+
+use super::ChatWidget;
+
+#[derive(Debug)]
+pub(crate) enum QueuedInterrupt {
+ ExecApproval(String, ExecApprovalRequestEvent),
+ ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
+ ExecBegin(ExecCommandBeginEvent),
+ ExecEnd(ExecCommandEndEvent),
+ McpBegin(McpToolCallBeginEvent),
+ McpEnd(McpToolCallEndEvent),
+ PatchEnd(PatchApplyEndEvent),
+}
+
+#[derive(Default)]
+pub(crate) struct InterruptManager {
+ queue: VecDeque,
+}
+
+impl InterruptManager {
+ pub(crate) fn new() -> Self {
+ Self {
+ queue: VecDeque::new(),
+ }
+ }
+
+ #[inline]
+ pub(crate) fn is_empty(&self) -> bool {
+ self.queue.is_empty()
+ }
+
+ pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev));
+ }
+
+ pub(crate) fn push_apply_patch_approval(
+ &mut self,
+ id: String,
+ ev: ApplyPatchApprovalRequestEvent,
+ ) {
+ self.queue
+ .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
+ }
+
+ pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
+ }
+
+ pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
+ self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
+ }
+
+ pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
+ self.queue.push_back(QueuedInterrupt::McpBegin(ev));
+ }
+
+ pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
+ self.queue.push_back(QueuedInterrupt::McpEnd(ev));
+ }
+
+ pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
+ self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
+ }
+
+ pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
+ while let Some(q) = self.queue.pop_front() {
+ match q {
+ QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),
+ QueuedInterrupt::ApplyPatchApproval(id, ev) => {
+ chat.handle_apply_patch_approval_now(id, ev)
+ }
+ QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
+ QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
+ QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
+ QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
+ QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
+ }
+ }
+ }
+}
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
new file mode 100644
index 0000000000..0a7dd93368
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -0,0 +1,925 @@
+#![allow(clippy::unwrap_used, clippy::expect_used, unnameable_test_items)]
+
+use super::*;
+use crate::app_event::AppEvent;
+use crate::app_event_sender::AppEventSender;
+use codex_core::config::Config;
+use codex_core::config::ConfigOverrides;
+use codex_core::config::ConfigToml;
+use codex_core::plan_tool::PlanItemArg;
+use codex_core::plan_tool::StepStatus;
+use codex_core::plan_tool::UpdatePlanArgs;
+use codex_core::protocol::AgentMessageDeltaEvent;
+use codex_core::protocol::AgentMessageEvent;
+use codex_core::protocol::AgentReasoningDeltaEvent;
+use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+use codex_core::protocol::Event;
+use codex_core::protocol::EventMsg;
+use codex_core::protocol::ExecCommandBeginEvent;
+use codex_core::protocol::ExecCommandEndEvent;
+use codex_core::protocol::FileChange;
+use codex_core::protocol::PatchApplyBeginEvent;
+use codex_core::protocol::PatchApplyEndEvent;
+use codex_core::protocol::TaskCompleteEvent;
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use crossterm::event::KeyModifiers;
+use pretty_assertions::assert_eq;
+use std::fs::File;
+use std::io::BufRead;
+use std::io::BufReader;
+use std::io::Read;
+use std::path::PathBuf;
+use std::sync::mpsc::channel;
+use tokio::sync::mpsc::unbounded_channel;
+
+fn test_config() -> Config {
+ // Use base defaults to avoid depending on host state.
+ codex_core::config::Config::load_from_base_config_with_overrides(
+ ConfigToml::default(),
+ ConfigOverrides::default(),
+ std::env::temp_dir(),
+ )
+ .expect("config")
+}
+
+#[test]
+fn final_answer_without_newline_is_flushed_immediately() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Set up a VT100 test terminal to capture ANSI visual output
+ let width: u16 = 80;
+ let height: u16 = 2000;
+ let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
+ let backend = ratatui::backend::TestBackend::new(width, height);
+ let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
+ .expect("failed to construct terminal");
+ terminal.set_viewport_area(viewport);
+
+ // Simulate a streaming answer without any newline characters.
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
+ delta: "Hi! How can I help with codex-rs or anything else today?".into(),
+ }),
+ });
+
+ // Now simulate the final AgentMessage which should flush the pending line immediately.
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: "Hi! How can I help with codex-rs or anything else today?".into(),
+ }),
+ });
+
+ // Drain history insertions and verify the final line is present.
+ let cells = drain_insert_history(&rx);
+ assert!(
+ cells.iter().any(|lines| {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ s.contains("codex")
+ }),
+ "expected 'codex' header to be emitted",
+ );
+ let found_final = cells.iter().any(|lines| {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ s.contains("Hi! How can I help with codex-rs or anything else today?")
+ });
+ assert!(
+ found_final,
+ "expected final answer text to be flushed to history"
+ );
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn helpers_are_available_and_do_not_panic() {
+ let (tx_raw, _rx) = channel::();
+ let tx = AppEventSender::new(tx_raw);
+ let cfg = test_config();
+ let conversation_manager = Arc::new(ConversationManager::default());
+ let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false);
+ // Basic construction sanity.
+ let _ = &mut w;
+}
+
+// --- Helpers for tests that need direct construction and event draining ---
+fn make_chatwidget_manual() -> (
+ ChatWidget<'static>,
+ std::sync::mpsc::Receiver,
+ tokio::sync::mpsc::UnboundedReceiver,
+) {
+ let (tx_raw, rx) = channel::();
+ let app_event_tx = AppEventSender::new(tx_raw);
+ let (op_tx, op_rx) = unbounded_channel::();
+ let cfg = test_config();
+ let bottom = BottomPane::new(BottomPaneParams {
+ app_event_tx: app_event_tx.clone(),
+ has_input_focus: true,
+ enhanced_keys_supported: false,
+ });
+ let widget = ChatWidget {
+ app_event_tx,
+ codex_op_tx: op_tx,
+ bottom_pane: bottom,
+ active_exec_cell: None,
+ config: cfg.clone(),
+ initial_user_message: None,
+ total_token_usage: TokenUsage::default(),
+ last_token_usage: TokenUsage::default(),
+ stream: StreamController::new(cfg),
+ last_stream_kind: None,
+ running_commands: HashMap::new(),
+ pending_exec_completions: Vec::new(),
+ task_complete_pending: false,
+ interrupts: InterruptManager::new(),
+ needs_redraw: false,
+ };
+ (widget, rx, op_rx)
+}
+
+fn drain_insert_history(
+ rx: &std::sync::mpsc::Receiver,
+) -> Vec>> {
+ let mut out = Vec::new();
+ while let Ok(ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = ev {
+ out.push(lines);
+ }
+ }
+ out
+}
+
+fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
+ let mut s = String::new();
+ for line in lines {
+ for span in &line.spans {
+ s.push_str(&span.content);
+ }
+ s.push('\n');
+ }
+ s
+}
+
+fn open_fixture(name: &str) -> std::fs::File {
+ // 1) Prefer fixtures within this crate
+ {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("tests");
+ p.push("fixtures");
+ p.push(name);
+ if let Ok(f) = File::open(&p) {
+ return f;
+ }
+ }
+ // 2) Fallback to parent (workspace root)
+ {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("..");
+ p.push(name);
+ if let Ok(f) = File::open(&p) {
+ return f;
+ }
+ }
+ // 3) Last resort: CWD
+ File::open(name).expect("open fixture file")
+}
+
+#[test]
+fn exec_history_cell_shows_working_then_completed() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Begin command
+ chat.handle_codex_event(Event {
+ id: "call-1".into(),
+ msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
+ call_id: "call-1".into(),
+ command: vec!["bash".into(), "-lc".into(), "echo done".into()],
+ cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
+ parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
+ cmd: vec!["echo".into(), "done".into()],
+ }],
+ }),
+ });
+
+ // End command successfully
+ chat.handle_codex_event(Event {
+ id: "call-1".into(),
+ msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
+ call_id: "call-1".into(),
+ stdout: "done".into(),
+ stderr: String::new(),
+ exit_code: 0,
+ duration: std::time::Duration::from_millis(5),
+ }),
+ });
+
+ let cells = drain_insert_history(&rx);
+ assert_eq!(
+ cells.len(),
+ 1,
+ "expected only the completed exec cell to be inserted into history"
+ );
+ let blob = lines_to_single_string(&cells[0]);
+ assert!(
+ blob.contains("Completed"),
+ "expected completed exec cell to show Completed header: {blob:?}"
+ );
+}
+
+#[test]
+fn exec_history_cell_shows_working_then_failed() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Begin command
+ chat.handle_codex_event(Event {
+ id: "call-2".into(),
+ msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
+ call_id: "call-2".into(),
+ command: vec!["bash".into(), "-lc".into(), "false".into()],
+ cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
+ parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
+ cmd: vec!["false".into()],
+ }],
+ }),
+ });
+
+ // End command with failure
+ chat.handle_codex_event(Event {
+ id: "call-2".into(),
+ msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
+ call_id: "call-2".into(),
+ stdout: String::new(),
+ stderr: "error".into(),
+ exit_code: 2,
+ duration: std::time::Duration::from_millis(7),
+ }),
+ });
+
+ let cells = drain_insert_history(&rx);
+ assert_eq!(
+ cells.len(),
+ 1,
+ "expected only the completed exec cell to be inserted into history"
+ );
+ let blob = lines_to_single_string(&cells[0]);
+ assert!(
+ blob.contains("Failed (exit 2)"),
+ "expected completed exec cell to show Failed header with exit code: {blob:?}"
+ );
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn binary_size_transcript_matches_ideal_fixture() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Set up a VT100 test terminal to capture ANSI visual output
+ let width: u16 = 80;
+ let height: u16 = 2000;
+ let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
+ let backend = ratatui::backend::TestBackend::new(width, height);
+ let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
+ .expect("failed to construct terminal");
+ terminal.set_viewport_area(viewport);
+
+ // Replay the recorded session into the widget and collect transcript
+ let file = open_fixture("binary-size-log.jsonl");
+ let reader = BufReader::new(file);
+ let mut transcript = String::new();
+ let mut ansi: Vec = Vec::new();
+
+ for line in reader.lines() {
+ let line = line.expect("read line");
+ if line.trim().is_empty() || line.starts_with('#') {
+ continue;
+ }
+ let Ok(v): Result = serde_json::from_str(&line) else {
+ continue;
+ };
+ let Some(dir) = v.get("dir").and_then(|d| d.as_str()) else {
+ continue;
+ };
+ if dir != "to_tui" {
+ continue;
+ }
+ let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
+ continue;
+ };
+
+ match kind {
+ "codex_event" => {
+ if let Some(payload) = v.get("payload") {
+ let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
+ chat.handle_codex_event(ev);
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = app_ev {
+ transcript.push_str(&lines_to_single_string(&lines));
+ crate::insert_history::insert_history_lines_to_writer(
+ &mut terminal,
+ &mut ansi,
+ lines,
+ );
+ }
+ }
+ }
+ }
+ "app_event" => {
+ if let Some(variant) = v.get("variant").and_then(|s| s.as_str()) {
+ if variant == "CommitTick" {
+ chat.on_commit_tick();
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = app_ev {
+ transcript.push_str(&lines_to_single_string(&lines));
+ crate::insert_history::insert_history_lines_to_writer(
+ &mut terminal,
+ &mut ansi,
+ lines,
+ );
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ // Read the ideal fixture as-is
+ let mut f = open_fixture("ideal-binary-response.txt");
+ let mut ideal = String::new();
+ f.read_to_string(&mut ideal)
+ .expect("read ideal-binary-response.txt");
+ // Normalize line endings for Windows vs. Unix checkouts
+ let ideal = ideal.replace("\r\n", "\n");
+
+ // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
+ // and drop trailing empty lines so the shape matches the ideal fixture exactly.
+ let mut parser = vt100::Parser::new(height, width, 0);
+ parser.process(&ansi);
+ let mut lines: Vec = Vec::with_capacity(height as usize);
+ for row in 0..height {
+ let mut s = String::with_capacity(width as usize);
+ for col in 0..width {
+ if let Some(cell) = parser.screen().cell(row, col) {
+ if let Some(ch) = cell.contents().chars().next() {
+ s.push(ch);
+ } else {
+ s.push(' ');
+ }
+ } else {
+ s.push(' ');
+ }
+ }
+ // Trim trailing spaces to match plain text fixture
+ lines.push(s.trim_end().to_string());
+ }
+ while lines.last().is_some_and(|l| l.is_empty()) {
+ lines.pop();
+ }
+ // Compare only after the last session banner marker, and start at the next 'thinking' line.
+ const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
+ let last_marker_line_idx = lines
+ .iter()
+ .rposition(|l| l.starts_with(MARKER_PREFIX))
+ .expect("marker not found in visible output");
+ let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
+ .find(|&idx| lines[idx].trim_start() == "thinking")
+ .expect("no 'thinking' line found after marker");
+
+ let mut compare_lines: Vec = Vec::new();
+ // Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
+ compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
+ compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
+ let visible_after = compare_lines.join("\n");
+
+ // Optionally update the fixture when env var is set
+ if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
+ let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ p.push("tests");
+ p.push("fixtures");
+ p.push("ideal-binary-response.txt");
+ std::fs::write(&p, &visible_after).expect("write updated ideal fixture");
+ return;
+ }
+
+ // Exact equality with pretty diff on failure
+ assert_eq!(visible_after, ideal);
+}
+
+#[test]
+fn final_longer_answer_after_single_char_delta_is_complete() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Simulate a stray delta without newline (e.g., punctuation).
+ chat.handle_codex_event(Event {
+ id: "sub-x".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: "?".into() }),
+ });
+
+ // Now send the full final answer with no newline.
+ let full = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?";
+ chat.handle_codex_event(Event {
+ id: "sub-x".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: full.into(),
+ }),
+ });
+
+ // Drain and assert the full message appears in history.
+ let cells = drain_insert_history(&rx);
+ let mut found = false;
+ for lines in &cells {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::();
+ if s.contains(full) {
+ found = true;
+ break;
+ }
+ }
+ assert!(
+ found,
+ "expected full final message to be flushed to history, cells={:?}",
+ cells.len()
+ );
+}
+
+#[test]
+fn apply_patch_events_emit_history_cells() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // 1) Approval request -> proposed patch summary cell
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("foo.txt"),
+ FileChange::Add {
+ content: "hello\n".to_string(),
+ },
+ );
+ let ev = ApplyPatchApprovalRequestEvent {
+ call_id: "c1".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ev),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected pending patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("proposed patch"),
+ "missing proposed patch header: {blob:?}"
+ );
+
+ // 2) Begin apply -> applying patch cell
+ let mut changes2 = HashMap::new();
+ changes2.insert(
+ PathBuf::from("foo.txt"),
+ FileChange::Add {
+ content: "hello\n".to_string(),
+ },
+ );
+ let begin = PatchApplyBeginEvent {
+ call_id: "c1".into(),
+ auto_approved: true,
+ changes: changes2,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::PatchApplyBegin(begin),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected applying patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("Applying patch"),
+ "missing applying patch header: {blob:?}"
+ );
+
+ // 3) End apply success -> success cell
+ let end = PatchApplyEndEvent {
+ call_id: "c1".into(),
+ stdout: "ok\n".into(),
+ stderr: String::new(),
+ success: true,
+ };
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::PatchApplyEnd(end),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected applied patch cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("Applied patch"),
+ "missing applied patch header: {blob:?}"
+ );
+}
+
+#[test]
+fn apply_patch_approval_sends_op_with_submission_id() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+ // Simulate receiving an approval request with a distinct submission id and call id
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("file.rs"),
+ FileChange::Add {
+ content: "fn main(){}\n".into(),
+ },
+ );
+ let ev = ApplyPatchApprovalRequestEvent {
+ call_id: "call-999".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ };
+ chat.handle_codex_event(Event {
+ id: "sub-123".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ev),
+ });
+
+ // Approve via key press 'y'
+ chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
+
+ // Expect a CodexOp with PatchApproval carrying the submission id, not call id
+ let mut found = false;
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev {
+ assert_eq!(id, "sub-123");
+ assert!(matches!(
+ decision,
+ codex_core::protocol::ReviewDecision::Approved
+ ));
+ found = true;
+ break;
+ }
+ }
+ assert!(found, "expected PatchApproval op to be sent");
+}
+
+#[test]
+fn apply_patch_full_flow_integration_like() {
+ let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
+
+ // 1) Backend requests approval
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("pkg.rs"),
+ FileChange::Add { content: "".into() },
+ );
+ chat.handle_codex_event(Event {
+ id: "sub-xyz".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
+ call_id: "call-1".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ }),
+ });
+
+ // 2) User approves via 'y' and App receives a CodexOp
+ chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
+ let mut maybe_op: Option = None;
+ while let Ok(app_ev) = rx.try_recv() {
+ if let AppEvent::CodexOp(op) = app_ev {
+ maybe_op = Some(op);
+ break;
+ }
+ }
+ let op = maybe_op.expect("expected CodexOp after key press");
+
+ // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx
+ chat.submit_op(op);
+ let forwarded = op_rx
+ .try_recv()
+ .expect("expected op forwarded to codex channel");
+ match forwarded {
+ Op::PatchApproval { id, decision } => {
+ assert_eq!(id, "sub-xyz");
+ assert!(matches!(
+ decision,
+ codex_core::protocol::ReviewDecision::Approved
+ ));
+ }
+ other => panic!("unexpected op forwarded: {other:?}"),
+ }
+
+ // 4) Simulate patch begin/end events from backend; ensure history cells are emitted
+ let mut changes2 = HashMap::new();
+ changes2.insert(
+ PathBuf::from("pkg.rs"),
+ FileChange::Add { content: "".into() },
+ );
+ chat.handle_codex_event(Event {
+ id: "sub-xyz".into(),
+ msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
+ call_id: "call-1".into(),
+ auto_approved: false,
+ changes: changes2,
+ }),
+ });
+ chat.handle_codex_event(Event {
+ id: "sub-xyz".into(),
+ msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
+ call_id: "call-1".into(),
+ stdout: String::from("ok"),
+ stderr: String::new(),
+ success: true,
+ }),
+ });
+}
+
+#[test]
+fn apply_patch_untrusted_shows_approval_modal() {
+ let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
+ // Ensure approval policy is untrusted (OnRequest)
+ chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
+
+ // Simulate a patch approval request from backend
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("a.rs"),
+ FileChange::Add { content: "".into() },
+ );
+ chat.handle_codex_event(Event {
+ id: "sub-1".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
+ call_id: "call-1".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ }),
+ });
+
+ // Render and ensure the approval modal title is present
+ let area = ratatui::layout::Rect::new(0, 0, 80, 12);
+ let mut buf = ratatui::buffer::Buffer::empty(area);
+ (&chat).render_ref(area, &mut buf);
+
+ let mut contains_title = false;
+ for y in 0..area.height {
+ let mut row = String::new();
+ for x in 0..area.width {
+ row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
+ }
+ if row.contains("Apply changes?") {
+ contains_title = true;
+ break;
+ }
+ }
+ assert!(
+ contains_title,
+ "expected approval modal to be visible with title 'Apply changes?'"
+ );
+}
+
+#[test]
+fn apply_patch_request_shows_diff_summary() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Ensure we are in OnRequest so an approval is surfaced
+ chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
+
+ // Simulate backend asking to apply a patch adding two lines to README.md
+ let mut changes = HashMap::new();
+ changes.insert(
+ PathBuf::from("README.md"),
+ FileChange::Add {
+ // Two lines (no trailing empty line counted)
+ content: "line one\nline two\n".into(),
+ },
+ );
+ chat.handle_codex_event(Event {
+ id: "sub-apply".into(),
+ msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
+ call_id: "call-apply".into(),
+ changes,
+ reason: None,
+ grant_root: None,
+ }),
+ });
+
+ // Drain history insertions and verify the diff summary is present
+ let cells = drain_insert_history(&rx);
+ assert!(
+ !cells.is_empty(),
+ "expected a history cell with the proposed patch summary"
+ );
+ let blob = lines_to_single_string(cells.last().unwrap());
+
+ // Header should summarize totals
+ assert!(
+ blob.contains("proposed patch to 1 file (+2 -0)"),
+ "missing or incorrect diff header: {blob:?}"
+ );
+
+ // Per-file summary line should include the file path and counts
+ assert!(
+ blob.contains("README.md (+2 -0)"),
+ "missing per-file diff summary: {blob:?}"
+ );
+}
+
+#[test]
+fn plan_update_renders_history_cell() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+ let update = UpdatePlanArgs {
+ explanation: Some("Adapting plan".to_string()),
+ plan: vec![
+ PlanItemArg {
+ step: "Explore codebase".into(),
+ status: StepStatus::Completed,
+ },
+ PlanItemArg {
+ step: "Implement feature".into(),
+ status: StepStatus::InProgress,
+ },
+ PlanItemArg {
+ step: "Write tests".into(),
+ status: StepStatus::Pending,
+ },
+ ],
+ };
+ chat.handle_codex_event(Event {
+ id: "sub-1".into(),
+ msg: EventMsg::PlanUpdate(update),
+ });
+ let cells = drain_insert_history(&rx);
+ assert!(!cells.is_empty(), "expected plan update cell to be sent");
+ let blob = lines_to_single_string(cells.last().unwrap());
+ assert!(
+ blob.contains("Update plan"),
+ "missing plan header: {blob:?}"
+ );
+ assert!(blob.contains("Explore codebase"));
+ assert!(blob.contains("Implement feature"));
+ assert!(blob.contains("Write tests"));
+}
+
+#[test]
+fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Answer: no header until a newline commit
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
+ delta: "Hello".into(),
+ }),
+ });
+ let mut saw_codex_pre = false;
+ while let Ok(ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = ev {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::>()
+ .join("");
+ if s.contains("codex") {
+ saw_codex_pre = true;
+ break;
+ }
+ }
+ }
+ assert!(
+ !saw_codex_pre,
+ "answer header should not be emitted before first newline commit"
+ );
+
+ // Newline arrives, then header is emitted
+ chat.handle_codex_event(Event {
+ id: "sub-a".into(),
+ msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
+ delta: "!\n".into(),
+ }),
+ });
+ chat.on_commit_tick();
+ let mut saw_codex_post = false;
+ while let Ok(ev) = rx.try_recv() {
+ if let AppEvent::InsertHistory(lines) = ev {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::>()
+ .join("");
+ if s.contains("codex") {
+ saw_codex_post = true;
+ break;
+ }
+ }
+ }
+ assert!(
+ saw_codex_post,
+ "expected 'codex' header to be emitted after first newline commit"
+ );
+
+ // Reasoning: header immediately
+ let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
+ chat2.handle_codex_event(Event {
+ id: "sub-b".into(),
+ msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
+ delta: "Thinking".into(),
+ }),
+ });
+ let mut saw_thinking = false;
+ while let Ok(ev) = rx2.try_recv() {
+ if let AppEvent::InsertHistory(lines) = ev {
+ let s = lines
+ .iter()
+ .flat_map(|l| l.spans.iter())
+ .map(|sp| sp.content.clone())
+ .collect::>()
+ .join("");
+ if s.contains("thinking") {
+ saw_thinking = true;
+ break;
+ }
+ }
+ }
+ assert!(
+ saw_thinking,
+ "expected 'thinking' header to be emitted at stream start"
+ );
+}
+
+#[test]
+fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
+ let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+
+ // Begin turn
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::TaskStarted,
+ });
+
+ // First finalized assistant message
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: "First message".into(),
+ }),
+ });
+
+ // Second finalized assistant message in the same turn
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::AgentMessage(AgentMessageEvent {
+ message: "Second message".into(),
+ }),
+ });
+
+ // End turn
+ chat.handle_codex_event(Event {
+ id: "s1".into(),
+ msg: EventMsg::TaskComplete(TaskCompleteEvent {
+ last_agent_message: None,
+ }),
+ });
+
+ let cells = drain_insert_history(&rx);
+ let mut header_count = 0usize;
+ let mut combined = String::new();
+ for lines in &cells {
+ for l in lines {
+ for sp in &l.spans {
+ let s = &sp.content;
+ if s == "codex" {
+ header_count += 1;
+ }
+ combined.push_str(s);
+ }
+ combined.push('\n');
+ }
+ }
+ assert_eq!(
+ header_count,
+ 2,
+ "expected two 'codex' headers for two AgentMessage events in one turn; cells={:?}",
+ cells.len()
+ );
+ assert!(
+ combined.contains("First message"),
+ "missing first message: {combined}"
+ );
+ assert!(
+ combined.contains("Second message"),
+ "missing second message: {combined}"
+ );
+ let first_idx = combined.find("First message").unwrap();
+ let second_idx = combined.find("Second message").unwrap();
+ assert!(first_idx < second_idx, "messages out of order: {combined}");
+}
diff --git a/codex-rs/tui/src/chatwidget_stream_tests.rs b/codex-rs/tui/src/chatwidget_stream_tests.rs
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget_stream_tests.rs
@@ -0,0 +1 @@
+
diff --git a/codex-rs/tui/src/common.rs b/codex-rs/tui/src/common.rs
new file mode 100644
index 0000000000..2c19b58706
--- /dev/null
+++ b/codex-rs/tui/src/common.rs
@@ -0,0 +1 @@
+pub(crate) const DEFAULT_WRAP_COLS: u16 = 80;
diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs
new file mode 100644
index 0000000000..9b37846b3f
--- /dev/null
+++ b/codex-rs/tui/src/diff_render.rs
@@ -0,0 +1,431 @@
+use crossterm::terminal;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line as RtLine;
+use ratatui::text::Span as RtSpan;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use crate::common::DEFAULT_WRAP_COLS;
+use codex_core::protocol::FileChange;
+
+use crate::history_cell::PatchEventType;
+
+const SPACES_AFTER_LINE_NUMBER: usize = 6;
+
+// Internal representation for diff line rendering
+enum DiffLineType {
+ Insert,
+ Delete,
+ Context,
+}
+
+pub(crate) fn create_diff_summary(
+ title: &str,
+ changes: &HashMap,
+ event_type: PatchEventType,
+) -> Vec> {
+ struct FileSummary {
+ display_path: String,
+ added: usize,
+ removed: usize,
+ }
+
+ let count_from_unified = |diff: &str| -> (usize, usize) {
+ if let Ok(patch) = diffy::Patch::from_str(diff) {
+ patch
+ .hunks()
+ .iter()
+ .flat_map(|h| h.lines())
+ .fold((0, 0), |(a, d), l| match l {
+ diffy::Line::Insert(_) => (a + 1, d),
+ diffy::Line::Delete(_) => (a, d + 1),
+ _ => (a, d),
+ })
+ } else {
+ // Fallback: manual scan to preserve counts even for unparsable diffs
+ let mut adds = 0usize;
+ let mut dels = 0usize;
+ for l in diff.lines() {
+ if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
+ continue;
+ }
+ match l.as_bytes().first() {
+ Some(b'+') => adds += 1,
+ Some(b'-') => dels += 1,
+ _ => {}
+ }
+ }
+ (adds, dels)
+ }
+ };
+
+ let mut files: Vec = Vec::new();
+ for (path, change) in changes.iter() {
+ match change {
+ FileChange::Add { content } => files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added: content.lines().count(),
+ removed: 0,
+ }),
+ FileChange::Delete => files.push(FileSummary {
+ display_path: path.display().to_string(),
+ added: 0,
+ removed: std::fs::read_to_string(path)
+ .ok()
+ .map(|s| s.lines().count())
+ .unwrap_or(0),
+ }),
+ FileChange::Update {
+ unified_diff,
+ move_path,
+ } => {
+ let (added, removed) = count_from_unified(unified_diff);
+ let display_path = if let Some(new_path) = move_path {
+ format!("{} → {}", path.display(), new_path.display())
+ } else {
+ path.display().to_string()
+ };
+ files.push(FileSummary {
+ display_path,
+ added,
+ removed,
+ });
+ }
+ }
+ }
+
+ let file_count = files.len();
+ let total_added: usize = files.iter().map(|f| f.added).sum();
+ let total_removed: usize = files.iter().map(|f| f.removed).sum();
+ let noun = if file_count == 1 { "file" } else { "files" };
+
+ let mut out: Vec> = Vec::new();
+
+ // Header
+ let mut header_spans: Vec> = Vec::new();
+ header_spans.push(RtSpan::styled(
+ title.to_owned(),
+ Style::default()
+ .fg(Color::Magenta)
+ .add_modifier(Modifier::BOLD),
+ ));
+ header_spans.push(RtSpan::raw(" to "));
+ header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
+ header_spans.push(RtSpan::raw("("));
+ header_spans.push(RtSpan::styled(
+ format!("+{total_added}"),
+ Style::default().fg(Color::Green),
+ ));
+ header_spans.push(RtSpan::raw(" "));
+ header_spans.push(RtSpan::styled(
+ format!("-{total_removed}"),
+ Style::default().fg(Color::Red),
+ ));
+ header_spans.push(RtSpan::raw(")"));
+ out.push(RtLine::from(header_spans));
+
+ // Dimmed per-file lines with prefix
+ for (idx, f) in files.iter().enumerate() {
+ let mut spans: Vec> = Vec::new();
+ spans.push(RtSpan::raw(f.display_path.clone()));
+ spans.push(RtSpan::raw(" ("));
+ spans.push(RtSpan::styled(
+ format!("+{}", f.added),
+ Style::default().fg(Color::Green),
+ ));
+ spans.push(RtSpan::raw(" "));
+ spans.push(RtSpan::styled(
+ format!("-{}", f.removed),
+ Style::default().fg(Color::Red),
+ ));
+ spans.push(RtSpan::raw(")"));
+
+ let mut line = RtLine::from(spans);
+ let prefix = if idx == 0 { " ⎿ " } else { " " };
+ line.spans.insert(0, prefix.into());
+ line.spans
+ .iter_mut()
+ .for_each(|span| span.style = span.style.add_modifier(Modifier::DIM));
+ out.push(line);
+ }
+
+ let show_details = matches!(
+ event_type,
+ PatchEventType::ApplyBegin {
+ auto_approved: true
+ } | PatchEventType::ApprovalRequest
+ );
+
+ if show_details {
+ out.extend(render_patch_details(changes));
+ }
+
+ out
+}
+
+fn render_patch_details(changes: &HashMap) -> Vec> {
+ let mut out: Vec> = Vec::new();
+ let term_cols: usize = terminal::size()
+ .map(|(w, _)| w as usize)
+ .unwrap_or(DEFAULT_WRAP_COLS.into());
+
+ for (index, (path, change)) in changes.iter().enumerate() {
+ let is_first_file = index == 0;
+ // Add separator only between files (not at the very start)
+ if !is_first_file {
+ out.push(RtLine::from(vec![
+ RtSpan::raw(" "),
+ RtSpan::styled("...", style_dim()),
+ ]));
+ }
+ match change {
+ FileChange::Add { content } => {
+ for (i, raw) in content.lines().enumerate() {
+ let ln = i + 1;
+ out.extend(push_wrapped_diff_line(
+ ln,
+ DiffLineType::Insert,
+ raw,
+ term_cols,
+ ));
+ }
+ }
+ FileChange::Delete => {
+ let original = std::fs::read_to_string(path).unwrap_or_default();
+ for (i, raw) in original.lines().enumerate() {
+ let ln = i + 1;
+ out.extend(push_wrapped_diff_line(
+ ln,
+ DiffLineType::Delete,
+ raw,
+ term_cols,
+ ));
+ }
+ }
+ FileChange::Update {
+ unified_diff,
+ move_path: _,
+ } => {
+ if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
+ for h in patch.hunks() {
+ let mut old_ln = h.old_range().start();
+ let mut new_ln = h.new_range().start();
+ for l in h.lines() {
+ match l {
+ diffy::Line::Insert(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ new_ln,
+ DiffLineType::Insert,
+ s,
+ term_cols,
+ ));
+ new_ln += 1;
+ }
+ diffy::Line::Delete(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ old_ln,
+ DiffLineType::Delete,
+ s,
+ term_cols,
+ ));
+ old_ln += 1;
+ }
+ diffy::Line::Context(text) => {
+ let s = text.trim_end_matches('\n');
+ out.extend(push_wrapped_diff_line(
+ new_ln,
+ DiffLineType::Context,
+ s,
+ term_cols,
+ ));
+ old_ln += 1;
+ new_ln += 1;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ out.push(RtLine::from(RtSpan::raw("")));
+ }
+
+ out
+}
+
+fn push_wrapped_diff_line(
+ line_number: usize,
+ kind: DiffLineType,
+ text: &str,
+ term_cols: usize,
+) -> Vec> {
+ let indent = " ";
+ let ln_str = line_number.to_string();
+ let mut remaining_text: &str = text;
+
+ // Reserve a fixed number of spaces after the line number so that content starts
+ // at a consistent column. The sign ("+"/"-") is rendered as part of the content
+ // and colored with the same foreground as the edited text, not as a separate
+ // dimmed column.
+ let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len());
+ let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+ let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln;
+
+ let mut first = true;
+ let (sign_opt, line_style) = match kind {
+ DiffLineType::Insert => (Some('+'), Some(style_add())),
+ DiffLineType::Delete => (Some('-'), Some(style_del())),
+ DiffLineType::Context => (None, None),
+ };
+ let mut lines: Vec