mirror of
https://github.com/openai/codex.git
synced 2026-02-04 16:03:46 +00:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bbf94bd93 | ||
|
|
76c209d78c | ||
|
|
d2fe780280 | ||
|
|
e172014062 | ||
|
|
d4cb5fcdbd | ||
|
|
549a5de99a | ||
|
|
e0418bf4b9 | ||
|
|
07c8dbc94d | ||
|
|
bb9be76328 | ||
|
|
b277a654fa | ||
|
|
8752a9b049 | ||
|
|
5af5856848 | ||
|
|
16882fa090 | ||
|
|
366d0738a4 | ||
|
|
032f14aec8 | ||
|
|
6ef0c2e8e7 | ||
|
|
5db76dc66e | ||
|
|
1a04fa0379 | ||
|
|
a5c14eb8c0 | ||
|
|
cd610fd409 | ||
|
|
35130cf21b | ||
|
|
311ad0ce26 | ||
|
|
5fa7d46ddf | ||
|
|
d994019f3f | ||
|
|
6de9541f0a | ||
|
|
85099017fd | ||
|
|
a5b2ebb49b | ||
|
|
697c7cf4bf | ||
|
|
34ac698bef | ||
|
|
097782c775 | ||
|
|
8ba8089592 | ||
|
|
57c498159a | ||
|
|
bbf42f4e12 | ||
|
|
6f0b499594 | ||
|
|
236c4f76a6 | ||
|
|
dc42ec0eb4 | ||
|
|
cdc77c10fb | ||
|
|
c5d21a4564 | ||
|
|
59f6b1654f | ||
|
|
80b00a193e | ||
|
|
76dc3f6054 | ||
|
|
e4c275d615 | ||
|
|
9f71dcbf57 | ||
|
|
750ca9e21d | ||
|
|
5fac7b2566 | ||
|
|
24c7be7da0 | ||
|
|
4b4aa2a774 | ||
|
|
16d16a4ddc | ||
|
|
9604671678 | ||
|
|
db934e438e | ||
|
|
5f6e1af1a5 | ||
|
|
8ad56be06e | ||
|
|
d2b2a6d13a | ||
|
|
74683bab91 | ||
|
|
dacff9675a | ||
|
|
697b4ce100 | ||
|
|
9193eb6b53 | ||
|
|
e95cad1946 | ||
|
|
2ec5a28528 | ||
|
|
050b9baeb6 | ||
|
|
5ab30c73f3 | ||
|
|
250ae37c84 | ||
|
|
c579ae41ae | ||
|
|
0d12380c3b | ||
|
|
1a1516a80b | ||
|
|
61bbabe7d9 | ||
|
|
8481eb4c6e | ||
|
|
0ad4e11c84 | ||
|
|
ee8c4ad23a | ||
|
|
202af12926 | ||
|
|
ce434b1219 | ||
|
|
d1f1e36836 | ||
|
|
eaae56a1b0 | ||
|
|
77148a5c61 | ||
|
|
17c98a7fd3 | ||
|
|
bc298e47ca | ||
|
|
0d6678936f |
4
.github/dependabot.yaml
vendored
4
.github/dependabot.yaml
vendored
@@ -24,3 +24,7 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: rust-toolchain
|
||||
directory: codex-rs
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
2
.github/workflows/codex.yml
vendored
2
.github/workflows/codex.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ github.workspace }}/codex-rs/target/
|
||||
key: cargo-ubuntu-24.04-x86_64-unknown-linux-gnu-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: cargo-ubuntu-24.04-x86_64-unknown-linux-gnu-dev-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Note it is possible that the `verify` step internal to Run Codex will
|
||||
# fail, in which case the work to setup the repo was worthless :(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/codex-cli/dist
|
||||
/codex-cli/node_modules
|
||||
pnpm-lock.yaml
|
||||
|
||||
prompt.md
|
||||
*_prompt.md
|
||||
*_instructions.md
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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`
|
||||
- Crate names are prefixed with `codex-`. For example, 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.
|
||||
|
||||
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
|
||||
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
|
||||
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
|
||||
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
|
||||
|
||||
|
||||
@@ -383,6 +383,13 @@ base_url = "http://my-ollama.example.com:11434/v1"
|
||||
|
||||
### Platform sandboxing details
|
||||
|
||||
By default, Codex CLI runs code and shell commands inside a restricted sandbox to protect your system.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Not all tool calls are sandboxed. Specifically, **trusted Model Context Protocol (MCP) tool calls** are executed outside of the sandbox.
|
||||
> This is intentional: MCP tools are explicitly configured and trusted by you, and they often need to connect to **external applications or services** (e.g. issue trackers, databases, messaging systems).
|
||||
> Running them outside the sandbox allows Codex to integrate with these external systems without being blocked by sandbox restrictions.
|
||||
|
||||
The mechanism Codex uses to implement the sandbox policy depends on your OS:
|
||||
|
||||
- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified.
|
||||
|
||||
199
codex-rs/Cargo.lock
generated
199
codex-rs/Cargo.lock
generated
@@ -186,6 +186,26 @@ version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
@@ -737,6 +757,7 @@ dependencies = [
|
||||
"tree-sitter-bash",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"which",
|
||||
"whoami",
|
||||
"wildmatch",
|
||||
"wiremock",
|
||||
@@ -753,6 +774,7 @@ dependencies = [
|
||||
"codex-arg0",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
"core_test_support",
|
||||
@@ -822,6 +844,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
@@ -900,13 +923,16 @@ dependencies = [
|
||||
name = "codex-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"mcp-types",
|
||||
"mime_guess",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
]
|
||||
@@ -926,6 +952,8 @@ name = "codex-tui"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -959,8 +987,10 @@ dependencies = [
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
"textwrap 0.16.2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -1161,6 +1191,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
@@ -1405,6 +1436,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display_container"
|
||||
version = "0.9.0"
|
||||
@@ -1858,6 +1899,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.23"
|
||||
@@ -3054,6 +3105,42 @@ dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3068,6 +3155,18 @@ checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3789,9 +3888,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.22"
|
||||
version = "0.12.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
|
||||
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4183,9 +4282,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.142"
|
||||
version = "1.0.143"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"itoa",
|
||||
@@ -5596,6 +5695,18 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"rustix 0.38.44",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.0"
|
||||
@@ -5829,6 +5940,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -5867,6 +5993,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5885,6 +6017,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5903,6 +6041,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5933,6 +6077,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5951,6 +6101,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -5969,6 +6125,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -5987,6 +6149,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -6008,6 +6176,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.4"
|
||||
@@ -6047,6 +6221,23 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix 0.38.44",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
To edit files, ALWAYS use the `shell` tool with `apply_patch` CLI. `apply_patch` effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the `apply_patch` CLI, you should call the shell tool with the following structure:
|
||||
## `apply_patch`
|
||||
|
||||
```bash
|
||||
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n[YOUR_PATCH]\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
|
||||
```
|
||||
Use the `apply_patch` shell command to edit files.
|
||||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
|
||||
*** Begin Patch
|
||||
[ one or more file sections ]
|
||||
*** End Patch
|
||||
|
||||
*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete.
|
||||
For each snippet of code that needs to be changed, repeat the following:
|
||||
[context_before] -> See below for further instructions on context.
|
||||
- [old_code] -> Precede the old code with a minus sign.
|
||||
+ [new_code] -> Precede the new, replacement code with a plus sign.
|
||||
[context_after] -> See below for further instructions on context.
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
|
||||
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
*** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
*** Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
May be immediately followed by *** Move to: <new path> if you want to rename the file.
|
||||
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
|
||||
Within a hunk each line starts with:
|
||||
|
||||
For instructions on [context_before] and [context_after]:
|
||||
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
|
||||
@@ -25,16 +31,45 @@ For instructions on [context_before] and [context_after]:
|
||||
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
|
||||
|
||||
@@ class BaseClass
|
||||
@@ def method():
|
||||
@@ def method():
|
||||
[3 lines of pre-context]
|
||||
- [old_code]
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
|
||||
The full grammar definition is below:
|
||||
Patch := Begin { FileOp } End
|
||||
Begin := "*** Begin Patch" NEWLINE
|
||||
End := "*** End Patch" NEWLINE
|
||||
FileOp := AddFile | DeleteFile | UpdateFile
|
||||
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
|
||||
DeleteFile := "*** Delete File: " path NEWLINE
|
||||
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
|
||||
MoveTo := "*** Move to: " newPath NEWLINE
|
||||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||||
|
||||
A full patch can combine several operations:
|
||||
|
||||
*** Begin Patch
|
||||
*** Add File: hello.txt
|
||||
+Hello world
|
||||
*** Update File: src/app.py
|
||||
*** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
*** Delete File: obsolete.txt
|
||||
*** End Patch
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
- File references can only be relative, NEVER ABSOLUTE.
|
||||
|
||||
You can invoke apply_patch like:
|
||||
|
||||
```bash
|
||||
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n*** Update File: pygorithm/searching/binary_search.py\\n@@ class BaseClass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n@@ class Subclass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
|
||||
```
|
||||
|
||||
File references can only be relative, NEVER ABSOLUTE. After the apply_patch command is run, it will always say "Done!", regardless of whether the patch was successfully applied or not. However, you can determine if there are issue and errors by looking at any warnings or logging lines printed BEFORE the "Done!" is output.
|
||||
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||||
```
|
||||
|
||||
@@ -22,6 +22,8 @@ use tree_sitter_bash::LANGUAGE as BASH;
|
||||
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
|
||||
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
|
||||
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ApplyPatchError {
|
||||
#[error(transparent)]
|
||||
@@ -82,7 +84,6 @@ 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 APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
|
||||
Ok(source) => MaybeApplyPatch::Body(source),
|
||||
@@ -91,7 +92,9 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
[bash, flag, script]
|
||||
if bash == "bash"
|
||||
&& flag == "-lc"
|
||||
&& script.trim_start().starts_with("apply_patch") =>
|
||||
&& APPLY_PATCH_COMMANDS
|
||||
.iter()
|
||||
.any(|cmd| script.trim_start().starts_with(cmd)) =>
|
||||
{
|
||||
match extract_heredoc_body_from_apply_patch_command(script) {
|
||||
Ok(body) => match parse_patch(&body) {
|
||||
@@ -262,7 +265,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
fn extract_heredoc_body_from_apply_patch_command(
|
||||
src: &str,
|
||||
) -> std::result::Result<String, ExtractHeredocError> {
|
||||
if !src.trim_start().starts_with("apply_patch") {
|
||||
if !APPLY_PATCH_COMMANDS
|
||||
.iter()
|
||||
.any(|cmd| src.trim_start().starts_with(cmd))
|
||||
{
|
||||
return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch);
|
||||
}
|
||||
|
||||
@@ -773,6 +779,33 @@ PATCH"#,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
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_add_file_hunk_creates_file_with_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -159,7 +159,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Mcp) => {
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe).await?;
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe, cli.config_overrides).await?;
|
||||
}
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides);
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_core::config::ConfigOverrides;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_login::AuthManager;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tracing::error;
|
||||
@@ -36,7 +37,10 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
|
||||
|
||||
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
|
||||
// Use conversation_manager API to start a conversation
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::new(AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
config.preferred_auth_method,
|
||||
));
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
|
||||
46
codex-rs/common/src/approval_presets.rs
Normal file
46
codex-rs/common/src/approval_presets.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
|
||||
/// A simple preset pairing an approval policy with a sandbox policy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalPreset {
|
||||
/// Stable identifier for the preset.
|
||||
pub id: &'static str,
|
||||
/// Display label shown in UIs.
|
||||
pub label: &'static str,
|
||||
/// Short human description shown next to the label in UIs.
|
||||
pub description: &'static str,
|
||||
/// Approval policy to apply.
|
||||
pub approval: AskForApproval,
|
||||
/// Sandbox policy to apply.
|
||||
pub sandbox: SandboxPolicy,
|
||||
}
|
||||
|
||||
/// Built-in list of approval presets that pair approval and sandbox policy.
|
||||
///
|
||||
/// Keep this UI-agnostic so it can be reused by both TUI and MCP server.
|
||||
pub fn builtin_approval_presets() -> Vec<ApprovalPreset> {
|
||||
vec![
|
||||
ApprovalPreset {
|
||||
id: "read-only",
|
||||
label: "Read Only",
|
||||
description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network",
|
||||
approval: AskForApproval::OnRequest,
|
||||
sandbox: SandboxPolicy::ReadOnly,
|
||||
},
|
||||
ApprovalPreset {
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network",
|
||||
approval: AskForApproval::OnRequest,
|
||||
sandbox: SandboxPolicy::new_workspace_write_policy(),
|
||||
},
|
||||
ApprovalPreset {
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution",
|
||||
approval: AskForApproval::Never,
|
||||
sandbox: SandboxPolicy::DangerFullAccess,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -31,3 +31,6 @@ pub use config_summary::create_config_summary_entries;
|
||||
pub mod fuzzy_match;
|
||||
// Shared model presets used by TUI and MCP server
|
||||
pub mod model_presets;
|
||||
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
|
||||
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
|
||||
pub mod approval_presets;
|
||||
|
||||
@@ -24,28 +24,28 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
|
||||
ModelPreset {
|
||||
id: "gpt-5-minimal",
|
||||
label: "gpt-5 minimal",
|
||||
description: "— Fastest responses with very limited reasoning; ideal for coding, instructions, or lightweight tasks.",
|
||||
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Minimal,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-low",
|
||||
label: "gpt-5 low",
|
||||
description: "— Balances speed with some reasoning; useful for straightforward queries and short explanations.",
|
||||
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Low,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-medium",
|
||||
label: "gpt-5 medium",
|
||||
description: "— Default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks.",
|
||||
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Medium,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-high",
|
||||
label: "gpt-5 high",
|
||||
description: "— Maximizes reasoning depth for complex or ambiguous problems.",
|
||||
description: "— maximizes reasoning depth for complex or ambiguous problems",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::High,
|
||||
},
|
||||
|
||||
@@ -243,6 +243,25 @@ To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in you
|
||||
model_reasoning_summary = "none" # disable reasoning summaries
|
||||
```
|
||||
|
||||
## model_verbosity
|
||||
|
||||
Controls output length/detail on GPT‑5 family models when using the Responses API. Supported values:
|
||||
|
||||
- `"low"`
|
||||
- `"medium"` (default when omitted)
|
||||
- `"high"`
|
||||
|
||||
When set, Codex includes a `text` object in the request payload with the configured verbosity, for example: `"text": { "verbosity": "low" }`.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
model = "gpt-5"
|
||||
model_verbosity = "low"
|
||||
```
|
||||
|
||||
Note: This applies only to providers using the Responses API. Chat Completions providers are unaffected.
|
||||
|
||||
## model_supports_reasoning_summaries
|
||||
|
||||
By default, `reasoning` is only set on requests to OpenAI models that are known to support them. To force `reasoning` to set on requests to the current model, you can force this behavior by setting the following in `config.toml`:
|
||||
@@ -300,6 +319,16 @@ This is reasonable to use if Codex is running in an environment that provides it
|
||||
|
||||
Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows.
|
||||
|
||||
## Approval presets
|
||||
|
||||
Codex provides three main Approval Presets:
|
||||
|
||||
- Read Only: Codex can read files and answer questions; edits, running commands, and network access require approval.
|
||||
- Auto: Codex can read files, make edits, and run commands in the workspace without approval; asks for approval outside the workspace or for network access.
|
||||
- Full Access: Full disk and network access without prompts; extremely risky.
|
||||
|
||||
You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options.
|
||||
|
||||
## mcp_servers
|
||||
|
||||
Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy).
|
||||
|
||||
@@ -71,6 +71,9 @@ openssl-sys = { version = "*", features = ["vendored"] }
|
||||
[target.aarch64-unknown-linux-musl.dependencies]
|
||||
openssl-sys = { version = "*", features = ["vendored"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
which = "6"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
core_test_support = { path = "tests/common" }
|
||||
|
||||
@@ -270,67 +270,6 @@ 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`
|
||||
|
||||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
**_ Begin Patch
|
||||
[ one or more file sections ]
|
||||
_** End Patch
|
||||
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
|
||||
**_ Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
_** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
\*\*\* Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
May be immediately followed by \*\*\* Move to: <new path> if you want to rename the file.
|
||||
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
|
||||
Within a hunk each line starts with:
|
||||
|
||||
- for inserted text,
|
||||
|
||||
* for removed text, or
|
||||
space ( ) for context.
|
||||
At the end of a truncated hunk you can emit \*\*\* End of File.
|
||||
|
||||
Patch := Begin { FileOp } End
|
||||
Begin := "**_ Begin Patch" NEWLINE
|
||||
End := "_** End Patch" NEWLINE
|
||||
FileOp := AddFile | DeleteFile | UpdateFile
|
||||
AddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }
|
||||
DeleteFile := "_** Delete File: " path NEWLINE
|
||||
UpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }
|
||||
MoveTo := "_** Move to: " newPath NEWLINE
|
||||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||||
|
||||
A full patch can combine several operations:
|
||||
|
||||
**_ Begin Patch
|
||||
_** Add File: hello.txt
|
||||
+Hello world
|
||||
**_ Update File: src/app.py
|
||||
_** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
**_ Delete File: obsolete.txt
|
||||
_** End Patch
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
|
||||
You can invoke apply_patch like:
|
||||
|
||||
```
|
||||
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||||
```
|
||||
|
||||
## `update_plan`
|
||||
|
||||
A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
use crate::models::ResponseInputItem;
|
||||
use crate::protocol::FileChange;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_patch_safety;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ use crate::client_common::ResponseStream;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ReasoningItemContent;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::openai_tools::create_tools_json_for_chat_completions_api;
|
||||
use crate::util::backoff;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
/// Implementation for the classic Chat Completions API.
|
||||
pub(crate) async fn stream_chat_completions(
|
||||
@@ -102,6 +102,33 @@ pub(crate) async fn stream_chat_completions(
|
||||
"content": output.content,
|
||||
}));
|
||||
}
|
||||
ResponseItem::CustomToolCall {
|
||||
id,
|
||||
call_id: _,
|
||||
name,
|
||||
input,
|
||||
status: _,
|
||||
} => {
|
||||
messages.push(json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": id,
|
||||
"type": "custom",
|
||||
"custom": {
|
||||
"name": name,
|
||||
"input": input,
|
||||
}
|
||||
}]
|
||||
}));
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"content": output,
|
||||
}));
|
||||
}
|
||||
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
|
||||
// Omit these items from the conversation history.
|
||||
continue;
|
||||
@@ -482,16 +509,19 @@ where
|
||||
// do NOT emit yet. Forward any other item (e.g. FunctionCall) right
|
||||
// away so downstream consumers see it.
|
||||
|
||||
let is_assistant_delta = matches!(&item, crate::models::ResponseItem::Message { role, .. } if role == "assistant");
|
||||
let is_assistant_delta = matches!(&item, codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant");
|
||||
|
||||
if is_assistant_delta {
|
||||
// Only use the final assistant message if we have not
|
||||
// seen any deltas; otherwise, deltas already built the
|
||||
// cumulative text and this would duplicate it.
|
||||
if this.cumulative.is_empty()
|
||||
&& let crate::models::ResponseItem::Message { content, .. } = &item
|
||||
&& let codex_protocol::models::ResponseItem::Message { content, .. } =
|
||||
&item
|
||||
&& let Some(text) = content.iter().find_map(|c| match c {
|
||||
crate::models::ContentItem::OutputText { text } => Some(text),
|
||||
codex_protocol::models::ContentItem::OutputText { text } => {
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
@@ -515,26 +545,27 @@ where
|
||||
if !this.cumulative_reasoning.is_empty()
|
||||
&& matches!(this.mode, AggregateMode::AggregatedOnly)
|
||||
{
|
||||
let aggregated_reasoning = crate::models::ResponseItem::Reasoning {
|
||||
id: String::new(),
|
||||
summary: Vec::new(),
|
||||
content: Some(vec![
|
||||
crate::models::ReasoningItemContent::ReasoningText {
|
||||
text: std::mem::take(&mut this.cumulative_reasoning),
|
||||
},
|
||||
]),
|
||||
encrypted_content: None,
|
||||
};
|
||||
let aggregated_reasoning =
|
||||
codex_protocol::models::ResponseItem::Reasoning {
|
||||
id: String::new(),
|
||||
summary: Vec::new(),
|
||||
content: Some(vec![
|
||||
codex_protocol::models::ReasoningItemContent::ReasoningText {
|
||||
text: std::mem::take(&mut this.cumulative_reasoning),
|
||||
},
|
||||
]),
|
||||
encrypted_content: None,
|
||||
};
|
||||
this.pending
|
||||
.push_back(ResponseEvent::OutputItemDone(aggregated_reasoning));
|
||||
emitted_any = true;
|
||||
}
|
||||
|
||||
if !this.cumulative.is_empty() {
|
||||
let aggregated_message = crate::models::ResponseItem::Message {
|
||||
let aggregated_message = codex_protocol::models::ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentItem::OutputText {
|
||||
content: vec![codex_protocol::models::ContentItem::OutputText {
|
||||
text: std::mem::take(&mut this.cumulative),
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::prelude::*;
|
||||
use regex_lite::Regex;
|
||||
@@ -28,6 +28,7 @@ use crate::client_common::ResponseEvent;
|
||||
use crate::client_common::ResponseStream;
|
||||
use crate::client_common::ResponsesApiRequest;
|
||||
use crate::client_common::create_reasoning_param_for_request;
|
||||
use crate::client_common::create_text_param_for_request;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
@@ -36,13 +37,13 @@ use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
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 codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -60,7 +61,7 @@ struct Error {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelClient {
|
||||
config: Arc<Config>,
|
||||
auth: Option<CodexAuth>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
client: reqwest::Client,
|
||||
provider: ModelProviderInfo,
|
||||
session_id: Uuid,
|
||||
@@ -71,7 +72,7 @@ pub struct ModelClient {
|
||||
impl ModelClient {
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
auth: Option<CodexAuth>,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
provider: ModelProviderInfo,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
@@ -79,7 +80,7 @@ impl ModelClient {
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
auth,
|
||||
auth_manager,
|
||||
client: reqwest::Client::new(),
|
||||
provider,
|
||||
session_id,
|
||||
@@ -140,7 +141,8 @@ impl ModelClient {
|
||||
return stream_from_fixture(path, self.provider.clone()).await;
|
||||
}
|
||||
|
||||
let auth = self.auth.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let auth = auth_manager.as_ref().and_then(|m| m.auth());
|
||||
|
||||
let auth_mode = auth.as_ref().map(|a| a.mode);
|
||||
|
||||
@@ -164,6 +166,19 @@ impl ModelClient {
|
||||
|
||||
let input_with_instructions = prompt.get_formatted_input();
|
||||
|
||||
// Only include `text.verbosity` for GPT-5 family models
|
||||
let text = if self.config.model_family.family == "gpt-5" {
|
||||
create_text_param_for_request(self.config.model_verbosity)
|
||||
} else {
|
||||
if self.config.model_verbosity.is_some() {
|
||||
warn!(
|
||||
"model_verbosity is set but ignored for non-gpt-5 model family: {}",
|
||||
self.config.model_family.family
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let payload = ResponsesApiRequest {
|
||||
model: &self.config.model,
|
||||
instructions: &full_instructions,
|
||||
@@ -176,6 +191,7 @@ impl ModelClient {
|
||||
stream: true,
|
||||
include,
|
||||
prompt_cache_key: Some(self.session_id.to_string()),
|
||||
text,
|
||||
};
|
||||
|
||||
let mut attempt = 0;
|
||||
@@ -208,11 +224,7 @@ impl ModelClient {
|
||||
req_builder = req_builder.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let originator = self
|
||||
.config
|
||||
.internal_originator
|
||||
.as_deref()
|
||||
.unwrap_or("codex_cli_rs");
|
||||
let originator = &self.config.responses_originator_header;
|
||||
req_builder = req_builder.header("originator", originator);
|
||||
req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
|
||||
|
||||
@@ -252,6 +264,13 @@ impl ModelClient {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED
|
||||
&& let Some(manager) = auth_manager.as_ref()
|
||||
&& manager.auth().is_some()
|
||||
{
|
||||
let _ = manager.refresh_token().await;
|
||||
}
|
||||
|
||||
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
|
||||
// errors. When we bubble early with only the HTTP status the caller sees an opaque
|
||||
// "unexpected status 400 Bad Request" which makes debugging nearly impossible.
|
||||
@@ -259,7 +278,10 @@ impl ModelClient {
|
||||
// exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is
|
||||
// small and this branch only runs on error paths so the extra allocation is
|
||||
// negligible.
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS
|
||||
|| status == StatusCode::UNAUTHORIZED
|
||||
|| status.is_server_error())
|
||||
{
|
||||
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(CodexErr::UnexpectedStatus(status, body));
|
||||
@@ -333,8 +355,8 @@ impl ModelClient {
|
||||
self.summary
|
||||
}
|
||||
|
||||
pub fn get_auth(&self) -> Option<CodexAuth> {
|
||||
self.auth.clone()
|
||||
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.auth_manager.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,6 +575,8 @@ async fn process_sse<S>(
|
||||
}
|
||||
"response.content_part.done"
|
||||
| "response.function_call_arguments.delta"
|
||||
| "response.custom_tool_call_input.delta"
|
||||
| "response.custom_tool_call_input.done" // also emitted as response.output_item.done
|
||||
| "response.in_progress"
|
||||
| "response.output_item.added"
|
||||
| "response.output_text.done" => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::config_types::Verbosity as VerbosityConfig;
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::protocol::TokenUsage;
|
||||
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
@@ -47,7 +48,18 @@ impl Prompt {
|
||||
.as_deref()
|
||||
.unwrap_or(BASE_INSTRUCTIONS);
|
||||
let mut sections: Vec<&str> = vec![base];
|
||||
if model.needs_special_apply_patch_instructions {
|
||||
|
||||
// When there are no custom instructions, add apply_patch_tool_instructions if either:
|
||||
// - the model needs special instructions (4.1), or
|
||||
// - there is no apply_patch tool present
|
||||
let is_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
|
||||
OpenAiTool::Function(f) => f.name == "apply_patch",
|
||||
OpenAiTool::Freeform(f) => f.name == "apply_patch",
|
||||
_ => false,
|
||||
});
|
||||
if self.base_instructions_override.is_none()
|
||||
&& (model.needs_special_apply_patch_instructions || !is_apply_patch_tool_present)
|
||||
{
|
||||
sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
|
||||
}
|
||||
Cow::Owned(sections.join("\n"))
|
||||
@@ -89,6 +101,32 @@ pub(crate) struct Reasoning {
|
||||
pub(crate) summary: ReasoningSummaryConfig,
|
||||
}
|
||||
|
||||
/// Controls under the `text` field in the Responses API for GPT-5.
|
||||
#[derive(Debug, Serialize, Default, Clone, Copy)]
|
||||
pub(crate) struct TextControls {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) verbosity: Option<OpenAiVerbosity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum OpenAiVerbosity {
|
||||
Low,
|
||||
#[default]
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
impl From<VerbosityConfig> for OpenAiVerbosity {
|
||||
fn from(v: VerbosityConfig) -> Self {
|
||||
match v {
|
||||
VerbosityConfig::Low => OpenAiVerbosity::Low,
|
||||
VerbosityConfig::Medium => OpenAiVerbosity::Medium,
|
||||
VerbosityConfig::High => OpenAiVerbosity::High,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request object that is serialized as JSON and POST'ed when using the
|
||||
/// Responses API.
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -109,6 +147,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
|
||||
pub(crate) include: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) prompt_cache_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) text: Option<TextControls>,
|
||||
}
|
||||
|
||||
pub(crate) fn create_reasoning_param_for_request(
|
||||
@@ -123,6 +163,14 @@ pub(crate) fn create_reasoning_param_for_request(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_text_param_for_request(
|
||||
verbosity: Option<VerbosityConfig>,
|
||||
) -> Option<TextControls> {
|
||||
verbosity.map(|v| TextControls {
|
||||
verbosity: Some(v.into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct ResponseStream {
|
||||
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
|
||||
}
|
||||
@@ -151,4 +199,57 @@ mod tests {
|
||||
let full = prompt.get_full_instructions(&model_family);
|
||||
assert_eq!(full, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_verbosity_when_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
let tools: Vec<serde_json::Value> = vec![];
|
||||
let req = ResponsesApiRequest {
|
||||
model: "gpt-5",
|
||||
instructions: "i",
|
||||
input: &input,
|
||||
tools: &tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
text: Some(TextControls {
|
||||
verbosity: Some(OpenAiVerbosity::Low),
|
||||
}),
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
assert_eq!(
|
||||
v.get("text")
|
||||
.and_then(|t| t.get("verbosity"))
|
||||
.and_then(|s| s.as_str()),
|
||||
Some("low")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_text_when_not_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
let tools: Vec<serde_json::Value> = vec![];
|
||||
let req = ResponsesApiRequest {
|
||||
model: "gpt-5",
|
||||
instructions: "i",
|
||||
input: &input,
|
||||
tools: &tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
text: None,
|
||||
};
|
||||
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
assert!(v.get("text").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::config_types::ShellEnvironmentPolicyToml;
|
||||
use crate::config_types::Tui;
|
||||
use crate::config_types::UriBasedFileOpener;
|
||||
use crate::config_types::Verbosity;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::model_family::find_family_for_model;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
@@ -35,6 +37,8 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
const DEFAULT_RESPONSES_ORIGINATOR_HEADER: &str = "codex_cli_rs";
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Config {
|
||||
@@ -148,6 +152,9 @@ pub struct Config {
|
||||
/// request using the Responses API.
|
||||
pub model_reasoning_summary: ReasoningSummary,
|
||||
|
||||
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
|
||||
@@ -162,8 +169,11 @@ pub struct Config {
|
||||
/// model family's default preference.
|
||||
pub include_apply_patch_tool: bool,
|
||||
|
||||
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
|
||||
pub include_subagent_tool: bool,
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
pub responses_originator_header: String,
|
||||
|
||||
/// If set to `true`, the API key will be signed with the `originator` header.
|
||||
pub preferred_auth_method: AuthMode,
|
||||
@@ -257,10 +267,61 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Mark the project as trusted. toml_edit is very good at handling
|
||||
// missing properties
|
||||
// Ensure we render a human-friendly structure:
|
||||
//
|
||||
// [projects]
|
||||
// [projects."/path/to/project"]
|
||||
// trust_level = "trusted"
|
||||
//
|
||||
// rather than inline tables like:
|
||||
//
|
||||
// [projects]
|
||||
// "/path/to/project" = { trust_level = "trusted" }
|
||||
let project_key = project_path.to_string_lossy().to_string();
|
||||
doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted");
|
||||
|
||||
// Ensure top-level `projects` exists as a non-inline, explicit table. If it
|
||||
// exists but was previously represented as a non-table (e.g., inline),
|
||||
// replace it with an explicit table.
|
||||
let mut created_projects_table = false;
|
||||
{
|
||||
let root = doc.as_table_mut();
|
||||
let needs_table = !root.contains_key("projects")
|
||||
|| root.get("projects").and_then(|i| i.as_table()).is_none();
|
||||
if needs_table {
|
||||
root.insert("projects", toml_edit::table());
|
||||
created_projects_table = true;
|
||||
}
|
||||
}
|
||||
let Some(projects_tbl) = doc["projects"].as_table_mut() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"projects table missing after initialization"
|
||||
));
|
||||
};
|
||||
|
||||
// If we created the `projects` table ourselves, keep it implicit so we
|
||||
// don't render a standalone `[projects]` header.
|
||||
if created_projects_table {
|
||||
projects_tbl.set_implicit(true);
|
||||
}
|
||||
|
||||
// Ensure the per-project entry is its own explicit table. If it exists but
|
||||
// is not a table (e.g., an inline table), replace it with an explicit table.
|
||||
let needs_proj_table = !projects_tbl.contains_key(project_key.as_str())
|
||||
|| projects_tbl
|
||||
.get(project_key.as_str())
|
||||
.and_then(|i| i.as_table())
|
||||
.is_none();
|
||||
if needs_proj_table {
|
||||
projects_tbl.insert(project_key.as_str(), toml_edit::table());
|
||||
}
|
||||
let Some(proj_tbl) = projects_tbl
|
||||
.get_mut(project_key.as_str())
|
||||
.and_then(|i| i.as_table_mut())
|
||||
else {
|
||||
return Err(anyhow::anyhow!("project table missing for {}", project_key));
|
||||
};
|
||||
proj_tbl.set_implicit(false);
|
||||
proj_tbl["trust_level"] = toml_edit::value("trusted");
|
||||
|
||||
// ensure codex_home exists
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
@@ -396,6 +457,8 @@ pub struct ConfigToml {
|
||||
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
@@ -410,12 +473,15 @@ pub struct ConfigToml {
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
pub responses_originator_header_internal_override: Option<String>,
|
||||
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
/// If set to `true`, the API key will be signed with the `originator` header.
|
||||
pub preferred_auth_method: Option<AuthMode>,
|
||||
|
||||
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
|
||||
pub include_subagent_tool: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -452,10 +518,27 @@ impl ConfigToml {
|
||||
pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool {
|
||||
let projects = self.projects.clone().unwrap_or_default();
|
||||
|
||||
projects
|
||||
.get(&resolved_cwd.to_string_lossy().to_string())
|
||||
.map(|p| p.trust_level.clone().unwrap_or("".to_string()) == "trusted")
|
||||
.unwrap_or(false)
|
||||
let is_path_trusted = |path: &Path| {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
projects
|
||||
.get(&path_str)
|
||||
.map(|p| p.trust_level.as_deref() == Some("trusted"))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// Fast path: exact cwd match
|
||||
if is_path_trusted(resolved_cwd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If cwd lives inside a git worktree, check whether the root git project
|
||||
// (the primary repository working directory) is trusted. This lets
|
||||
// worktrees inherit trust from the main project.
|
||||
if let Some(root_project) = resolve_root_git_project_for_trust(resolved_cwd) {
|
||||
return is_path_trusted(&root_project);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn get_config_profile(
|
||||
@@ -493,6 +576,7 @@ pub struct ConfigOverrides {
|
||||
pub base_instructions: Option<String>,
|
||||
pub include_plan_tool: Option<bool>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_subagent_tool: Option<bool>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
}
|
||||
@@ -519,6 +603,7 @@ impl Config {
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_subagent_tool,
|
||||
disable_response_storage,
|
||||
show_raw_agent_reasoning,
|
||||
} = overrides;
|
||||
@@ -595,7 +680,7 @@ impl Config {
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries,
|
||||
uses_local_shell_tool: false,
|
||||
uses_apply_patch_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -622,8 +707,9 @@ impl Config {
|
||||
Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
|
||||
let include_apply_patch_tool_val =
|
||||
include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool);
|
||||
let responses_originator_header: String = cfg
|
||||
.responses_originator_header_internal_override
|
||||
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
|
||||
|
||||
let config = Self {
|
||||
model,
|
||||
@@ -669,7 +755,7 @@ impl Config {
|
||||
.model_reasoning_summary
|
||||
.or(cfg.model_reasoning_summary)
|
||||
.unwrap_or_default(),
|
||||
|
||||
model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity),
|
||||
chatgpt_base_url: config_profile
|
||||
.chatgpt_base_url
|
||||
.or(cfg.chatgpt_base_url)
|
||||
@@ -677,8 +763,13 @@ impl Config {
|
||||
|
||||
experimental_resume,
|
||||
include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||
include_apply_patch_tool: include_apply_patch_tool_val,
|
||||
internal_originator: cfg.internal_originator,
|
||||
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
|
||||
include_subagent_tool: config_profile
|
||||
.include_subagent_tool
|
||||
.or(cfg.include_subagent_tool)
|
||||
.or(include_subagent_tool)
|
||||
.unwrap_or(false),
|
||||
responses_originator_header,
|
||||
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
|
||||
};
|
||||
Ok(config)
|
||||
@@ -1038,12 +1129,14 @@ disable_response_storage = true
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: ReasoningEffort::High,
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_verbosity: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
include_subagent_tool: false,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -1091,12 +1184,14 @@ disable_response_storage = true
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: ReasoningEffort::default(),
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_verbosity: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
include_subagent_tool: false,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
};
|
||||
|
||||
@@ -1159,12 +1254,14 @@ disable_response_storage = true
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: ReasoningEffort::default(),
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_verbosity: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_resume: None,
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
include_subagent_tool: false,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
};
|
||||
|
||||
@@ -1172,4 +1269,74 @@ disable_response_storage = true
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// Call the function under test
|
||||
set_project_trusted(codex_home.path(), project_dir.path())?;
|
||||
|
||||
// Read back the generated config.toml and assert exact contents
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let contents = std::fs::read_to_string(&config_path)?;
|
||||
|
||||
let raw_path = project_dir.path().to_string_lossy();
|
||||
let path_str = if raw_path.contains('\\') {
|
||||
format!("'{}'", raw_path)
|
||||
} else {
|
||||
format!("\"{}\"", raw_path)
|
||||
};
|
||||
let expected = format!(
|
||||
r#"[projects.{path_str}]
|
||||
trust_level = "trusted"
|
||||
"#
|
||||
);
|
||||
assert_eq!(contents, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let project_dir = TempDir::new().unwrap();
|
||||
|
||||
// Seed config.toml with an inline project entry under [projects]
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let raw_path = project_dir.path().to_string_lossy();
|
||||
let path_str = if raw_path.contains('\\') {
|
||||
format!("'{}'", raw_path)
|
||||
} else {
|
||||
format!("\"{}\"", raw_path)
|
||||
};
|
||||
// Use a quoted key so backslashes don't require escaping on Windows
|
||||
let initial = format!(
|
||||
r#"[projects]
|
||||
{path_str} = {{ trust_level = "untrusted" }}
|
||||
"#
|
||||
);
|
||||
std::fs::create_dir_all(codex_home.path())?;
|
||||
std::fs::write(&config_path, initial)?;
|
||||
|
||||
// Run the function; it should convert to explicit tables and set trusted
|
||||
set_project_trusted(codex_home.path(), project_dir.path())?;
|
||||
|
||||
let contents = std::fs::read_to_string(&config_path)?;
|
||||
|
||||
// Assert exact output after conversion to explicit table
|
||||
let expected = format!(
|
||||
r#"[projects]
|
||||
|
||||
[projects.{path_str}]
|
||||
trust_level = "trusted"
|
||||
"#
|
||||
);
|
||||
assert_eq!(contents, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// No test enforcing the presence of a standalone [projects] header.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config_types::Verbosity;
|
||||
use crate::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -17,6 +18,9 @@ pub struct ConfigProfile {
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
/// Include the `subagent.run` tool allowing the model to invoke configured subagents.
|
||||
pub include_subagent_tool: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::path::PathBuf;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum_macros::Display;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct McpServerConfig {
|
||||
@@ -183,3 +185,43 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
#[default]
|
||||
Medium,
|
||||
High,
|
||||
/// Option to disable reasoning.
|
||||
None,
|
||||
}
|
||||
|
||||
/// A summary of the reasoning performed by the model. This can be useful for
|
||||
/// debugging and understanding the model's reasoning process.
|
||||
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum ReasoningSummary {
|
||||
#[default]
|
||||
Auto,
|
||||
Concise,
|
||||
Detailed,
|
||||
/// Option to disable reasoning summaries.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Controls output length/detail on GPT-5 models via the Responses API.
|
||||
/// Serialized with lowercase values to match the OpenAI API.
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum Verbosity {
|
||||
Low,
|
||||
#[default]
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::models::ResponseItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
/// Transcript of conversation history
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -66,7 +66,7 @@ impl ConversationHistory {
|
||||
self.items.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentItem::OutputText {
|
||||
content: vec![codex_protocol::models::ContentItem::OutputText {
|
||||
text: delta.to_string(),
|
||||
}],
|
||||
});
|
||||
@@ -110,6 +110,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
ResponseItem::Message { role, .. } => role.as_str() != "system",
|
||||
ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::Reasoning { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
@@ -118,11 +120,11 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
|
||||
/// Helper to append the textual content from `src` into `dst` in place.
|
||||
fn append_text_content(
|
||||
dst: &mut Vec<crate::models::ContentItem>,
|
||||
src: &Vec<crate::models::ContentItem>,
|
||||
dst: &mut Vec<codex_protocol::models::ContentItem>,
|
||||
src: &Vec<codex_protocol::models::ContentItem>,
|
||||
) {
|
||||
for c in src {
|
||||
if let crate::models::ContentItem::OutputText { text } = c {
|
||||
if let codex_protocol::models::ContentItem::OutputText { text } = c {
|
||||
append_text_delta(dst, text);
|
||||
}
|
||||
}
|
||||
@@ -130,15 +132,15 @@ fn append_text_content(
|
||||
|
||||
/// Append a single text delta to the last OutputText item in `content`, or
|
||||
/// push a new OutputText item if none exists.
|
||||
fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str) {
|
||||
if let Some(crate::models::ContentItem::OutputText { text }) = content
|
||||
fn append_text_delta(content: &mut Vec<codex_protocol::models::ContentItem>, delta: &str) {
|
||||
if let Some(codex_protocol::models::ContentItem::OutputText { text }) = content
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|c| matches!(c, crate::models::ContentItem::OutputText { .. }))
|
||||
.find(|c| matches!(c, codex_protocol::models::ContentItem::OutputText { .. }))
|
||||
{
|
||||
text.push_str(delta);
|
||||
} else {
|
||||
content.push(crate::models::ContentItem::OutputText {
|
||||
content.push(codex_protocol::models::ContentItem::OutputText {
|
||||
text: delta.to_string(),
|
||||
});
|
||||
}
|
||||
@@ -147,7 +149,7 @@ fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ContentItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
fn assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
@@ -15,6 +16,7 @@ use crate::error::Result as CodexResult;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
/// Represents a newly created Codex conversation, including the first event
|
||||
/// (which is [`EventMsg::SessionConfigured`]).
|
||||
@@ -28,34 +30,48 @@ pub struct NewConversation {
|
||||
/// maintaining them in memory.
|
||||
pub struct ConversationManager {
|
||||
conversations: Arc<RwLock<HashMap<Uuid, Arc<CodexConversation>>>>,
|
||||
}
|
||||
|
||||
impl Default for ConversationManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
conversations: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
impl ConversationManager {
|
||||
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
|
||||
let auth = CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method)?;
|
||||
self.new_conversation_with_auth(config, auth).await
|
||||
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
conversations: Arc::new(RwLock::new(HashMap::new())),
|
||||
auth_manager,
|
||||
}
|
||||
}
|
||||
|
||||
/// Used for integration tests: should not be used by ordinary business
|
||||
/// logic.
|
||||
pub async fn new_conversation_with_auth(
|
||||
/// Construct with a dummy AuthManager containing the provided CodexAuth.
|
||||
/// Used for integration tests: should not be used by ordinary business logic.
|
||||
pub fn with_auth(auth: CodexAuth) -> Self {
|
||||
Self::new(codex_login::AuthManager::from_auth_for_testing(auth))
|
||||
}
|
||||
|
||||
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
|
||||
self.spawn_conversation(config, self.auth_manager.clone())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_conversation(
|
||||
&self,
|
||||
config: Config,
|
||||
auth: Option<CodexAuth>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
} = Codex::spawn(config, auth).await?;
|
||||
} = {
|
||||
let initial_history = None;
|
||||
Codex::spawn(config, auth_manager, initial_history).await?
|
||||
};
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
}
|
||||
|
||||
async fn finalize_spawn(
|
||||
&self,
|
||||
codex: Codex,
|
||||
conversation_id: Uuid,
|
||||
) -> CodexResult<NewConversation> {
|
||||
// The first event must be `SessionInitialized`. Validate and forward it
|
||||
// to the caller so that they can display it in the conversation
|
||||
// history.
|
||||
@@ -93,4 +109,120 @@ impl ConversationManager {
|
||||
.cloned()
|
||||
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
|
||||
}
|
||||
|
||||
/// Fork an existing conversation by dropping the last `drop_last_messages`
|
||||
/// user/assistant messages from its transcript and starting a new
|
||||
/// conversation with identical configuration (unless overridden by the
|
||||
/// caller's `config`). The new conversation will have a fresh id.
|
||||
pub async fn fork_conversation(
|
||||
&self,
|
||||
conversation_history: Vec<ResponseItem>,
|
||||
num_messages_to_drop: usize,
|
||||
config: Config,
|
||||
) -> CodexResult<NewConversation> {
|
||||
// Compute the prefix up to the cut point.
|
||||
let truncated_history =
|
||||
truncate_after_dropping_last_messages(conversation_history, num_messages_to_drop);
|
||||
|
||||
// Spawn a new conversation with the computed initial history.
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, Some(truncated_history)).await?;
|
||||
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a prefix of `items` obtained by dropping the last `n` user messages
|
||||
/// and all items that follow them.
|
||||
fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) -> Vec<ResponseItem> {
|
||||
if n == 0 || items.is_empty() {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Walk backwards counting only `user` Message items, find cut index.
|
||||
let mut count = 0usize;
|
||||
let mut cut_index = 0usize;
|
||||
for (idx, item) in items.iter().enumerate().rev() {
|
||||
if let ResponseItem::Message { role, .. } = item
|
||||
&& role == "user"
|
||||
{
|
||||
count += 1;
|
||||
if count == n {
|
||||
// Cut everything from this user message to the end.
|
||||
cut_index = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if count < n {
|
||||
// If fewer than n messages exist, drop everything.
|
||||
Vec::new()
|
||||
} else {
|
||||
items.into_iter().take(cut_index).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
fn user_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_from_last_user_only() {
|
||||
let items = vec![
|
||||
user_msg("u1"),
|
||||
assistant_msg("a1"),
|
||||
assistant_msg("a2"),
|
||||
user_msg("u2"),
|
||||
assistant_msg("a3"),
|
||||
ResponseItem::Reasoning {
|
||||
id: "r1".to_string(),
|
||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||
text: "s".to_string(),
|
||||
}],
|
||||
content: None,
|
||||
encrypted_content: None,
|
||||
},
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "tool".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "c1".to_string(),
|
||||
},
|
||||
assistant_msg("a4"),
|
||||
];
|
||||
|
||||
let truncated = truncate_after_dropping_last_messages(items.clone(), 1);
|
||||
assert_eq!(
|
||||
truncated,
|
||||
vec![items[0].clone(), items[1].clone(), items[2].clone()]
|
||||
);
|
||||
|
||||
let truncated2 = truncate_after_dropping_last_messages(items, 2);
|
||||
assert!(truncated2.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum_macros::Display as DeriveDisplay;
|
||||
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use std::fmt::Display;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// wraps environment context message in a tag for the model to parse more easily.
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>\n";
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>";
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_END: &str = "</environment_context>";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
|
||||
@@ -24,52 +24,87 @@ pub enum NetworkAccess {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "environment_context", rename_all = "snake_case")]
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub cwd: PathBuf,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub sandbox_mode: SandboxMode,
|
||||
pub network_access: NetworkAccess,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
pub network_access: Option<NetworkAccess>,
|
||||
pub shell: Option<Shell>,
|
||||
}
|
||||
|
||||
impl EnvironmentContext {
|
||||
pub fn new(
|
||||
cwd: PathBuf,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
cwd: Option<PathBuf>,
|
||||
approval_policy: Option<AskForApproval>,
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
shell: Option<Shell>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode: match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => SandboxMode::DangerFullAccess,
|
||||
SandboxPolicy::ReadOnly => SandboxMode::ReadOnly,
|
||||
SandboxPolicy::WorkspaceWrite { .. } => SandboxMode::WorkspaceWrite,
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly),
|
||||
Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite),
|
||||
None => None,
|
||||
},
|
||||
network_access: match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => NetworkAccess::Enabled,
|
||||
SandboxPolicy::ReadOnly => NetworkAccess::Restricted,
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
|
||||
Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled),
|
||||
Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted),
|
||||
Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => {
|
||||
if network_access {
|
||||
NetworkAccess::Enabled
|
||||
Some(NetworkAccess::Enabled)
|
||||
} else {
|
||||
NetworkAccess::Restricted
|
||||
Some(NetworkAccess::Restricted)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
shell,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for EnvironmentContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
"Current working directory: {}",
|
||||
self.cwd.to_string_lossy()
|
||||
)?;
|
||||
writeln!(f, "Approval policy: {}", self.approval_policy)?;
|
||||
writeln!(f, "Sandbox mode: {}", self.sandbox_mode)?;
|
||||
writeln!(f, "Network access: {}", self.network_access)?;
|
||||
Ok(())
|
||||
impl EnvironmentContext {
|
||||
/// Serializes the environment context to XML. Libraries like `quick-xml`
|
||||
/// require custom macros to handle Enums with newtypes, so we just do it
|
||||
/// manually, to keep things simple. Output looks like:
|
||||
///
|
||||
/// ```xml
|
||||
/// <environment_context>
|
||||
/// <cwd>...</cwd>
|
||||
/// <approval_policy>...</approval_policy>
|
||||
/// <sandbox_mode>...</sandbox_mode>
|
||||
/// <network_access>...</network_access>
|
||||
/// <shell>...</shell>
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()];
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
if let Some(approval_policy) = self.approval_policy {
|
||||
lines.push(format!(
|
||||
" <approval_policy>{}</approval_policy>",
|
||||
approval_policy
|
||||
));
|
||||
}
|
||||
if let Some(sandbox_mode) = self.sandbox_mode {
|
||||
lines.push(format!(" <sandbox_mode>{}</sandbox_mode>", sandbox_mode));
|
||||
}
|
||||
if let Some(network_access) = self.network_access {
|
||||
lines.push(format!(
|
||||
" <network_access>{}</network_access>",
|
||||
network_access
|
||||
));
|
||||
}
|
||||
if let Some(shell) = self.shell
|
||||
&& let Some(shell_name) = shell.name()
|
||||
{
|
||||
lines.push(format!(" <shell>{}</shell>", shell_name));
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_END.to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +114,7 @@ impl From<EnvironmentContext> for ResponseItem {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!("{ENVIRONMENT_CONTEXT_START}{ec}{ENVIRONMENT_CONTEXT_END}"),
|
||||
text: ec.serialize_to_xml(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,18 +28,17 @@ use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use serde_bytes::ByteBuf;
|
||||
|
||||
// Maximum we send for each stream, which is either:
|
||||
// - 10KiB OR
|
||||
// - 256 lines
|
||||
const MAX_STREAM_OUTPUT: usize = 10 * 1024;
|
||||
const MAX_STREAM_OUTPUT_LINES: usize = 256;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
// Hardcode these since it does not seem worth including the libc crate just
|
||||
// for these.
|
||||
const SIGKILL_CODE: i32 = 9;
|
||||
const TIMEOUT_CODE: i32 = 64;
|
||||
const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal
|
||||
|
||||
// I/O buffer sizing
|
||||
const READ_CHUNK_SIZE: usize = 8192; // bytes per read
|
||||
const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecParams {
|
||||
@@ -153,6 +152,7 @@ pub async fn process_exec_tool_call(
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output: raw_output.aggregated_output.from_utf8_lossy(),
|
||||
duration,
|
||||
})
|
||||
}
|
||||
@@ -189,10 +189,11 @@ pub struct StreamOutput<T> {
|
||||
pub truncated_after_lines: Option<u32>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct RawExecToolCallOutput {
|
||||
struct RawExecToolCallOutput {
|
||||
pub exit_status: ExitStatus,
|
||||
pub stdout: StreamOutput<Vec<u8>>,
|
||||
pub stderr: StreamOutput<Vec<u8>>,
|
||||
pub aggregated_output: StreamOutput<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl StreamOutput<String> {
|
||||
@@ -213,11 +214,17 @@ impl StreamOutput<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn append_all(dst: &mut Vec<u8>, src: &[u8]) {
|
||||
dst.extend_from_slice(src);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecToolCallOutput {
|
||||
pub exit_code: i32,
|
||||
pub stdout: StreamOutput<String>,
|
||||
pub stderr: StreamOutput<String>,
|
||||
pub aggregated_output: StreamOutput<String>,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
@@ -253,7 +260,7 @@ async fn exec(
|
||||
|
||||
/// 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(
|
||||
async fn consume_truncated_output(
|
||||
mut child: Child,
|
||||
timeout: Duration,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
@@ -273,19 +280,19 @@ pub(crate) async fn consume_truncated_output(
|
||||
))
|
||||
})?;
|
||||
|
||||
let (agg_tx, agg_rx) = async_channel::unbounded::<Vec<u8>>();
|
||||
|
||||
let stdout_handle = tokio::spawn(read_capped(
|
||||
BufReader::new(stdout_reader),
|
||||
MAX_STREAM_OUTPUT,
|
||||
MAX_STREAM_OUTPUT_LINES,
|
||||
stdout_stream.clone(),
|
||||
false,
|
||||
Some(agg_tx.clone()),
|
||||
));
|
||||
let stderr_handle = tokio::spawn(read_capped(
|
||||
BufReader::new(stderr_reader),
|
||||
MAX_STREAM_OUTPUT,
|
||||
MAX_STREAM_OUTPUT_LINES,
|
||||
stdout_stream.clone(),
|
||||
true,
|
||||
Some(agg_tx.clone()),
|
||||
));
|
||||
|
||||
let exit_status = tokio::select! {
|
||||
@@ -297,38 +304,48 @@ pub(crate) async fn consume_truncated_output(
|
||||
// timeout
|
||||
child.start_kill()?;
|
||||
// Debatable whether `child.wait().await` should be called here.
|
||||
synthetic_exit_status(128 + TIMEOUT_CODE)
|
||||
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
child.start_kill()?;
|
||||
synthetic_exit_status(128 + SIGKILL_CODE)
|
||||
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + SIGKILL_CODE)
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = stdout_handle.await??;
|
||||
let stderr = stderr_handle.await??;
|
||||
|
||||
drop(agg_tx);
|
||||
|
||||
let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
|
||||
while let Ok(chunk) = agg_rx.recv().await {
|
||||
append_all(&mut combined_buf, &chunk);
|
||||
}
|
||||
let aggregated_output = StreamOutput {
|
||||
text: combined_buf,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
|
||||
Ok(RawExecToolCallOutput {
|
||||
exit_status,
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
|
||||
mut reader: R,
|
||||
max_output: usize,
|
||||
max_lines: usize,
|
||||
stream: Option<StdoutStream>,
|
||||
is_stderr: bool,
|
||||
aggregate_tx: Option<Sender<Vec<u8>>>,
|
||||
) -> io::Result<StreamOutput<Vec<u8>>> {
|
||||
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
|
||||
let mut tmp = [0u8; 8192];
|
||||
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
|
||||
let mut tmp = [0u8; READ_CHUNK_SIZE];
|
||||
|
||||
let mut remaining_bytes = max_output;
|
||||
let mut remaining_lines = max_lines;
|
||||
// No caps: append all bytes
|
||||
|
||||
loop {
|
||||
let n = reader.read(&mut tmp).await?;
|
||||
@@ -355,33 +372,17 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
|
||||
let _ = stream.tx_event.send(event).await;
|
||||
}
|
||||
|
||||
// Copy into the buffer only while we still have byte and line budget.
|
||||
if remaining_bytes > 0 && remaining_lines > 0 {
|
||||
let mut copy_len = 0;
|
||||
for &b in &tmp[..n] {
|
||||
if remaining_bytes == 0 || remaining_lines == 0 {
|
||||
break;
|
||||
}
|
||||
copy_len += 1;
|
||||
remaining_bytes -= 1;
|
||||
if b == b'\n' {
|
||||
remaining_lines -= 1;
|
||||
}
|
||||
}
|
||||
buf.extend_from_slice(&tmp[..copy_len]);
|
||||
if let Some(tx) = &aggregate_tx {
|
||||
let _ = tx.send(tmp[..n].to_vec()).await;
|
||||
}
|
||||
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
|
||||
}
|
||||
|
||||
let truncated = remaining_lines == 0 || remaining_bytes == 0;
|
||||
append_all(&mut buf, &tmp[..n]);
|
||||
// Continue reading to EOF to avoid back-pressure
|
||||
}
|
||||
|
||||
Ok(StreamOutput {
|
||||
text: buf,
|
||||
truncated_after_lines: if truncated {
|
||||
Some((max_lines - remaining_lines) as u32)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
truncated_after_lines: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::mcp_protocol::GitSha;
|
||||
use futures::future::join_all;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::Duration as TokioDuration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::util::is_inside_git_repo;
|
||||
|
||||
/// Timeout for git commands to prevent freezing on large repositories
|
||||
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
|
||||
@@ -22,6 +28,12 @@ pub struct GitInfo {
|
||||
pub repository_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GitDiffToRemote {
|
||||
pub sha: GitSha,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
/// Collect git repository information from the given working directory using command-line git.
|
||||
/// Returns None if no git repository is found or if git operations fail.
|
||||
/// Uses timeouts to prevent freezing on large repositories.
|
||||
@@ -80,6 +92,23 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
||||
Some(git_info)
|
||||
}
|
||||
|
||||
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
||||
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
||||
if !is_inside_git_repo(cwd) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let remotes = get_git_remotes(cwd).await?;
|
||||
let branches = branch_ancestry(cwd).await?;
|
||||
let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
|
||||
let diff = diff_against_sha(cwd, &base_sha).await?;
|
||||
|
||||
Some(GitDiffToRemote {
|
||||
sha: base_sha,
|
||||
diff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a git command with a timeout to prevent blocking on large repositories
|
||||
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
let result = timeout(
|
||||
@@ -94,6 +123,341 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
||||
let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
||||
let origin = remotes.remove(pos);
|
||||
remotes.insert(0, origin);
|
||||
}
|
||||
Some(remotes)
|
||||
}
|
||||
|
||||
/// Attempt to determine the repository's default branch name.
|
||||
///
|
||||
/// Preference order:
|
||||
/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
||||
/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
||||
/// 3) Local fallback to existing `main` or `master` if present
|
||||
async fn get_default_branch(cwd: &Path) -> Option<String> {
|
||||
// Prefer the first remote (with origin prioritized)
|
||||
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
||||
for remote in remotes {
|
||||
// Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
||||
if let Some(symref_output) = run_git_command_with_timeout(
|
||||
&[
|
||||
"symbolic-ref",
|
||||
"--quiet",
|
||||
&format!("refs/remotes/{remote}/HEAD"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& symref_output.status.success()
|
||||
&& let Ok(sym) = String::from_utf8(symref_output.stdout)
|
||||
{
|
||||
let trimmed = sym.trim();
|
||||
if let Some((_, name)) = trimmed.rsplit_once('/') {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to parsing `git remote show <remote>` output
|
||||
if let Some(show_output) =
|
||||
run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
||||
&& show_output.status.success()
|
||||
&& let Ok(text) = String::from_utf8(show_output.stdout)
|
||||
{
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
||||
let name = rest.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No remote-derived default; try common local defaults if they exist
|
||||
for candidate in ["main", "master"] {
|
||||
if let Some(verify) = run_git_command_with_timeout(
|
||||
&[
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&format!("refs/heads/{candidate}"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& verify.status.success()
|
||||
{
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build an ancestry of branches starting at the current branch and ending at the
|
||||
/// repository's default branch (if determinable)..
|
||||
async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
||||
// Discover current branch (ignore detached HEAD by treating it as None)
|
||||
let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
||||
.await
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s != "HEAD");
|
||||
|
||||
// Discover default branch
|
||||
let default_branch = get_default_branch(cwd).await;
|
||||
|
||||
let mut ancestry: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
if let Some(cb) = current_branch.clone() {
|
||||
seen.insert(cb.clone());
|
||||
ancestry.push(cb);
|
||||
}
|
||||
if let Some(db) = default_branch
|
||||
&& !seen.contains(&db)
|
||||
{
|
||||
seen.insert(db.clone());
|
||||
ancestry.push(db);
|
||||
}
|
||||
|
||||
// Expand candidates: include any remote branches that already contain HEAD.
|
||||
// This addresses cases where we're on a new local-only branch forked from a
|
||||
// remote branch that isn't the repository default. We prioritize remotes in
|
||||
// the order returned by get_git_remotes (origin first).
|
||||
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
||||
for remote in remotes {
|
||||
if let Some(output) = run_git_command_with_timeout(
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"--contains=HEAD",
|
||||
&format!("refs/remotes/{remote}"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& output.status.success()
|
||||
&& let Ok(text) = String::from_utf8(output.stdout)
|
||||
{
|
||||
for line in text.lines() {
|
||||
let short = line.trim();
|
||||
// Expect format like: "origin/feature"; extract the branch path after "remote/"
|
||||
if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
|
||||
&& !stripped.is_empty()
|
||||
&& !seen.contains(stripped)
|
||||
{
|
||||
seen.insert(stripped.to_string());
|
||||
ancestry.push(stripped.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
||||
Some(ancestry)
|
||||
}
|
||||
|
||||
// Helper for a single branch: return the remote SHA if present on any remote
|
||||
// and the distance (commits ahead of HEAD) for that branch. The first item is
|
||||
// None if the branch is not present on any remote. Returns None if distance
|
||||
// could not be computed due to git errors/timeouts.
|
||||
async fn branch_remote_and_distance(
|
||||
cwd: &Path,
|
||||
branch: &str,
|
||||
remotes: &[String],
|
||||
) -> Option<(Option<GitSha>, usize)> {
|
||||
// Try to find the first remote ref that exists for this branch (origin prioritized by caller).
|
||||
let mut found_remote_sha: Option<GitSha> = None;
|
||||
let mut found_remote_ref: Option<String> = None;
|
||||
for remote in remotes {
|
||||
let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
||||
let Some(verify_output) =
|
||||
run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
|
||||
.await
|
||||
else {
|
||||
// Mirror previous behavior: if the verify call times out/fails at the process level,
|
||||
// treat the entire branch as unusable.
|
||||
return None;
|
||||
};
|
||||
if !verify_output.status.success() {
|
||||
continue;
|
||||
}
|
||||
let Ok(sha) = String::from_utf8(verify_output.stdout) else {
|
||||
// Mirror previous behavior and skip the entire branch on parse failure.
|
||||
return None;
|
||||
};
|
||||
found_remote_sha = Some(GitSha::new(sha.trim()));
|
||||
found_remote_ref = Some(remote_ref);
|
||||
break;
|
||||
}
|
||||
|
||||
// Compute distance as the number of commits HEAD is ahead of the branch.
|
||||
// Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
|
||||
let count_output = if let Some(local_count) =
|
||||
run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
|
||||
.await
|
||||
{
|
||||
if local_count.status.success() {
|
||||
local_count
|
||||
} else if let Some(remote_ref) = &found_remote_ref {
|
||||
match run_git_command_with_timeout(
|
||||
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(remote_count) => remote_count,
|
||||
None => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else if let Some(remote_ref) = &found_remote_ref {
|
||||
match run_git_command_with_timeout(
|
||||
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(remote_count) => remote_count,
|
||||
None => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if !count_output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
|
||||
return None;
|
||||
};
|
||||
let Ok(distance) = distance_str.trim().parse::<usize>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some((found_remote_sha, distance))
|
||||
}
|
||||
|
||||
// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
|
||||
async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
|
||||
// A sha and how many commits away from HEAD it is.
|
||||
let mut closest_sha: Option<(GitSha, usize)> = None;
|
||||
for branch in branches {
|
||||
let Some((maybe_remote_sha, distance)) =
|
||||
branch_remote_and_distance(cwd, branch, remotes).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(remote_sha) = maybe_remote_sha else {
|
||||
// Preserve existing behavior: skip branches that are not present on a remote.
|
||||
continue;
|
||||
};
|
||||
match &closest_sha {
|
||||
None => closest_sha = Some((remote_sha, distance)),
|
||||
Some((_, best_distance)) if distance < *best_distance => {
|
||||
closest_sha = Some((remote_sha, distance));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
closest_sha.map(|(sha, _)| sha)
|
||||
}
|
||||
|
||||
async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
||||
let output = run_git_command_with_timeout(&["diff", &sha.0], cwd).await?;
|
||||
// 0 is success and no diff.
|
||||
// 1 is success but there is a diff.
|
||||
let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
|
||||
if !exit_ok {
|
||||
return None;
|
||||
}
|
||||
let mut diff = String::from_utf8(output.stdout).ok()?;
|
||||
|
||||
if let Some(untracked_output) =
|
||||
run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
|
||||
&& untracked_output.status.success()
|
||||
{
|
||||
let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if !untracked.is_empty() {
|
||||
let futures_iter = untracked.into_iter().map(|file| async move {
|
||||
let file_owned = file;
|
||||
let args_vec: Vec<&str> =
|
||||
vec!["diff", "--binary", "--no-index", "/dev/null", &file_owned];
|
||||
run_git_command_with_timeout(&args_vec, cwd).await
|
||||
});
|
||||
let results = join_all(futures_iter).await;
|
||||
for extra in results.into_iter().flatten() {
|
||||
if extra.status.code().is_some_and(|c| c == 0 || c == 1)
|
||||
&& let Ok(s) = String::from_utf8(extra.stdout)
|
||||
{
|
||||
diff.push_str(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(diff)
|
||||
}
|
||||
|
||||
/// Resolve the path that should be used for trust checks. Similar to
|
||||
/// `[utils::is_inside_git_repo]`, but resolves to the root of the main
|
||||
/// repository. Handles worktrees.
|
||||
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
|
||||
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
||||
|
||||
// TODO: we should make this async, but it's primarily used deep in
|
||||
// callstacks of sync code, and should almost always be fast
|
||||
let git_dir_out = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--git-common-dir"])
|
||||
.current_dir(base)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !git_dir_out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let git_dir_s = String::from_utf8(git_dir_out.stdout)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let git_dir_path_raw = if Path::new(&git_dir_s).is_absolute() {
|
||||
PathBuf::from(&git_dir_s)
|
||||
} else {
|
||||
base.join(&git_dir_s)
|
||||
};
|
||||
|
||||
// Normalize to handle macOS /var vs /private/var and resolve ".." segments.
|
||||
let git_dir_path = std::fs::canonicalize(&git_dir_path_raw).unwrap_or(git_dir_path_raw);
|
||||
git_dir_path.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -104,7 +468,8 @@ mod tests {
|
||||
|
||||
// Helper function to create a test git repository
|
||||
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
fs::create_dir(&repo_path).expect("Failed to create repo dir");
|
||||
let envs = vec![
|
||||
("GIT_CONFIG_GLOBAL", "/dev/null"),
|
||||
("GIT_CONFIG_NOSYSTEM", "1"),
|
||||
@@ -159,6 +524,41 @@ mod tests {
|
||||
repo_path
|
||||
}
|
||||
|
||||
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
||||
let repo_path = create_test_git_repo(temp_dir).await;
|
||||
let remote_path = temp_dir.path().join("remote.git");
|
||||
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", remote_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to init bare remote");
|
||||
|
||||
Command::new("git")
|
||||
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to add remote");
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to get branch");
|
||||
let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
||||
|
||||
Command::new("git")
|
||||
.args(["push", "-u", "origin", &branch])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to push initial commit");
|
||||
|
||||
(repo_path, branch)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_git_info_non_git_directory() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -272,6 +672,210 @@ mod tests {
|
||||
assert_eq!(git_info.branch, Some("feature-branch".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_clean_repo() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_with_changes() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let tracked = repo_path.join("test.txt");
|
||||
fs::write(&tracked, "modified").unwrap();
|
||||
fs::write(repo_path.join("untracked.txt"), "new").unwrap();
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.contains("test.txt"));
|
||||
assert!(state.diff.contains("untracked.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_branch_fallback() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to create feature branch");
|
||||
Command::new("git")
|
||||
.args(["push", "-u", "origin", "feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to push feature branch");
|
||||
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "local-branch"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to create local branch");
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", "origin/feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
assert!(resolve_root_git_project_for_trust(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
let expected = std::fs::canonicalize(&repo_path).unwrap().to_path_buf();
|
||||
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&repo_path),
|
||||
Some(expected.clone())
|
||||
);
|
||||
let nested = repo_path.join("sub/dir");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&nested),
|
||||
Some(expected.clone())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let repo_path = create_test_git_repo(&temp_dir).await;
|
||||
|
||||
// Create a linked worktree
|
||||
let wt_root = temp_dir.path().join("wt");
|
||||
let _ = std::process::Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_root.to_str().unwrap(),
|
||||
"-b",
|
||||
"feature/x",
|
||||
])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.expect("git worktree add");
|
||||
|
||||
let expected = std::fs::canonicalize(&repo_path).ok();
|
||||
let got = resolve_root_git_project_for_trust(&wt_root)
|
||||
.and_then(|p| std::fs::canonicalize(p).ok());
|
||||
assert_eq!(got, expected);
|
||||
let nested = wt_root.join("nested/sub");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
let got_nested =
|
||||
resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok());
|
||||
assert_eq!(got_nested, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(proj.join("nested")).unwrap();
|
||||
|
||||
// `.git` is a file but does not point to a worktrees path
|
||||
std::fs::write(
|
||||
proj.join(".git"),
|
||||
format!(
|
||||
"gitdir: {}\n",
|
||||
tmp.path().join("some/other/location").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(resolve_root_git_project_for_trust(&proj).is_none());
|
||||
assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_unpushed_commit() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
fs::write(repo_path.join("test.txt"), "updated").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.txt"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to add file");
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "local change"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to commit");
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.contains("updated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_info_serialization() {
|
||||
let git_info = GitInfo {
|
||||
|
||||
@@ -39,16 +39,17 @@ mod conversation_manager;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
pub mod model_family;
|
||||
mod models;
|
||||
mod openai_model_info;
|
||||
mod openai_tools;
|
||||
pub mod plan_tool;
|
||||
mod project_doc;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod spawn;
|
||||
pub mod terminal;
|
||||
mod tool_apply_patch;
|
||||
pub mod turn_diff_tracker;
|
||||
pub mod user_agent;
|
||||
mod user_notification;
|
||||
@@ -61,3 +62,4 @@ pub use codex_protocol::protocol;
|
||||
// Re-export protocol config enums to ensure call sites can use the same types
|
||||
// as those in the protocol crate when constructing protocol messages.
|
||||
pub use codex_protocol::config_types as protocol_config_types;
|
||||
pub mod subagents;
|
||||
|
||||
@@ -4,13 +4,13 @@ use std::time::Instant;
|
||||
use tracing::error;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
use crate::models::ResponseInputItem;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
/// Handles the specified tool call dispatches the appropriate
|
||||
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::tool_apply_patch::ApplyPatchToolType;
|
||||
|
||||
/// A model family is a group of models that share certain characteristics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModelFamily {
|
||||
@@ -24,9 +26,9 @@ pub struct ModelFamily {
|
||||
// See https://platform.openai.com/docs/guides/tools-local-shell
|
||||
pub uses_local_shell_tool: bool,
|
||||
|
||||
/// True if the model performs better when `apply_patch` is provided as
|
||||
/// a tool call instead of just a bash command.
|
||||
pub uses_apply_patch_tool: bool,
|
||||
/// Present if the model performs better when `apply_patch` is provided as
|
||||
/// a tool call instead of just a bash command
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
}
|
||||
|
||||
macro_rules! model_family {
|
||||
@@ -40,7 +42,7 @@ macro_rules! model_family {
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries: false,
|
||||
uses_local_shell_tool: false,
|
||||
uses_apply_patch_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
};
|
||||
// apply overrides
|
||||
$(
|
||||
@@ -60,7 +62,7 @@ macro_rules! simple_model_family {
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries: false,
|
||||
uses_local_shell_tool: false,
|
||||
uses_apply_patch_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
})
|
||||
}};
|
||||
}
|
||||
@@ -88,6 +90,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
model_family!(
|
||||
slug, slug,
|
||||
supports_reasoning_summaries: true,
|
||||
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
|
||||
)
|
||||
} else if slug.starts_with("gpt-4.1") {
|
||||
model_family!(
|
||||
@@ -95,7 +98,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
needs_special_apply_patch_instructions: true,
|
||||
)
|
||||
} else if slug.starts_with("gpt-oss") {
|
||||
model_family!(slug, "gpt-oss", uses_apply_patch_tool: true)
|
||||
model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function))
|
||||
} else if slug.starts_with("gpt-4o") {
|
||||
simple_model_family!(slug, "gpt-4o")
|
||||
} else if slug.starts_with("gpt-3.5") {
|
||||
@@ -104,6 +107,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
model_family!(
|
||||
slug, "gpt-5",
|
||||
supports_reasoning_summaries: true,
|
||||
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -9,6 +9,9 @@ use crate::model_family::ModelFamily;
|
||||
use crate::plan_tool::PLAN_TOOL;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::tool_apply_patch::ApplyPatchToolType;
|
||||
use crate::tool_apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tool_apply_patch::create_apply_patch_json_tool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiTool {
|
||||
@@ -21,6 +24,20 @@ pub struct ResponsesApiTool {
|
||||
pub(crate) parameters: JsonSchema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformTool {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) format: FreeformToolFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeformToolFormat {
|
||||
pub(crate) r#type: String,
|
||||
pub(crate) syntax: String,
|
||||
pub(crate) definition: String,
|
||||
}
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
@@ -30,6 +47,8 @@ pub(crate) enum OpenAiTool {
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "custom")]
|
||||
Freeform(FreeformTool),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -43,7 +62,8 @@ pub enum ConfigShellToolType {
|
||||
pub struct ToolsConfig {
|
||||
pub shell_type: ConfigShellToolType,
|
||||
pub plan_tool: bool,
|
||||
pub apply_patch_tool: bool,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub subagent_tool: bool,
|
||||
}
|
||||
|
||||
impl ToolsConfig {
|
||||
@@ -53,6 +73,7 @@ impl ToolsConfig {
|
||||
sandbox_policy: SandboxPolicy,
|
||||
include_plan_tool: bool,
|
||||
include_apply_patch_tool: bool,
|
||||
include_subagent_tool: bool,
|
||||
) -> Self {
|
||||
let mut shell_type = if model_family.uses_local_shell_tool {
|
||||
ConfigShellToolType::LocalShell
|
||||
@@ -65,10 +86,23 @@ impl ToolsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
let apply_patch_tool_type = match model_family.apply_patch_tool_type {
|
||||
Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
|
||||
Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
|
||||
None => {
|
||||
if include_apply_patch_tool {
|
||||
Some(ApplyPatchToolType::Freeform)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
shell_type,
|
||||
plan_tool: include_plan_tool,
|
||||
apply_patch_tool: include_apply_patch_tool || model_family.uses_apply_patch_tool,
|
||||
apply_patch_tool_type,
|
||||
subagent_tool: include_subagent_tool,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,16 +149,20 @@ fn create_shell_tool() -> OpenAiTool {
|
||||
"command".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: None,
|
||||
description: Some("The command to execute".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String { description: None },
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout".to_string(),
|
||||
JsonSchema::Number { description: None },
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
@@ -155,7 +193,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout".to_string(),
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
@@ -171,7 +209,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Only set if ask_for_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -237,92 +275,16 @@ The shell tool is used to execute shell commands.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// TODO(dylan): deprecate once we get rid of json tool
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct ApplyPatchToolArgs {
|
||||
pub(crate) input: String,
|
||||
}
|
||||
|
||||
fn create_apply_patch_tool() -> OpenAiTool {
|
||||
// Minimal schema: one required string argument containing the patch body
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"input".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "apply_patch".to_string(),
|
||||
description: r#"Use this tool to edit files.
|
||||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
**_ Begin Patch
|
||||
[ one or more file sections ]
|
||||
_** End Patch
|
||||
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
|
||||
**_ Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
_** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
\*\*\* Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
May be immediately followed by \*\*\* Move to: <new path> if you want to rename the file.
|
||||
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
|
||||
Within a hunk each line starts with:
|
||||
|
||||
- for inserted text,
|
||||
|
||||
* for removed text, or
|
||||
space ( ) for context.
|
||||
At the end of a truncated hunk you can emit \*\*\* End of File.
|
||||
|
||||
Patch := Begin { FileOp } End
|
||||
Begin := "**_ Begin Patch" NEWLINE
|
||||
End := "_** End Patch" NEWLINE
|
||||
FileOp := AddFile | DeleteFile | UpdateFile
|
||||
AddFile := "**_ Add File: " path NEWLINE { "+" line NEWLINE }
|
||||
DeleteFile := "_** Delete File: " path NEWLINE
|
||||
UpdateFile := "**_ Update File: " path NEWLINE [ MoveTo ] { Hunk }
|
||||
MoveTo := "_** Move to: " newPath NEWLINE
|
||||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||||
|
||||
A full patch can combine several operations:
|
||||
|
||||
**_ Begin Patch
|
||||
_** Add File: hello.txt
|
||||
+Hello world
|
||||
**_ Update File: src/app.py
|
||||
_** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
**_ Delete File: obsolete.txt
|
||||
_** End Patch
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
"#
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["input".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns JSON values that are compatible with Function Calling in the
|
||||
/// Responses API:
|
||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||
pub(crate) fn create_tools_json_for_responses_api(
|
||||
pub fn create_tools_json_for_responses_api(
|
||||
tools: &Vec<OpenAiTool>,
|
||||
) -> crate::error::Result<Vec<serde_json::Value>> {
|
||||
let mut tools_json = Vec::new();
|
||||
@@ -539,8 +501,21 @@ pub(crate) fn get_openai_tools(
|
||||
tools.push(PLAN_TOOL.clone());
|
||||
}
|
||||
|
||||
if config.apply_patch_tool {
|
||||
tools.push(create_apply_patch_tool());
|
||||
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
|
||||
match apply_patch_tool_type {
|
||||
ApplyPatchToolType::Freeform => {
|
||||
tools.push(create_apply_patch_freeform_tool());
|
||||
}
|
||||
ApplyPatchToolType::Function => {
|
||||
tools.push(create_apply_patch_json_tool());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.subagent_tool {
|
||||
tracing::trace!("Adding subagent tool");
|
||||
tools.push(crate::subagents::SUBAGENT_TOOL.clone());
|
||||
tools.push(crate::subagents::SUBAGENT_LIST_TOOL.clone());
|
||||
}
|
||||
|
||||
if let Some(mcp_tools) = mcp_tools {
|
||||
@@ -554,6 +529,7 @@ pub(crate) fn get_openai_tools(
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!("Tools: {tools:?}");
|
||||
tools
|
||||
}
|
||||
|
||||
@@ -571,6 +547,7 @@ mod tests {
|
||||
.map(|tool| match tool {
|
||||
OpenAiTool::Function(ResponsesApiTool { name, .. }) => name,
|
||||
OpenAiTool::LocalShell {} => "local_shell",
|
||||
OpenAiTool::Freeform(FreeformTool { name, .. }) => name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -596,7 +573,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
true,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
@@ -611,7 +589,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
true,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let tools = get_openai_tools(&config, Some(HashMap::new()));
|
||||
|
||||
@@ -626,7 +605,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
false,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
let tools = get_openai_tools(
|
||||
&config,
|
||||
@@ -720,7 +700,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
false,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -776,7 +757,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
false,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -827,7 +809,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
false,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let tools = get_openai_tools(
|
||||
@@ -881,7 +864,8 @@ mod tests {
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::ReadOnly,
|
||||
false,
|
||||
model_family.uses_apply_patch_tool,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
let tools = get_openai_tools(
|
||||
|
||||
@@ -2,13 +2,13 @@ use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
use crate::models::ResponseInputItem;
|
||||
use crate::openai_tools::JsonSchema;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::openai_tools::ResponsesApiTool;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
// Use the canonical plan tool types from the protocol crate to ensure
|
||||
// type-identity matches events transported via `codex_protocol`.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
//! Project-level documentation discovery.
|
||||
//!
|
||||
//! Project-level documentation can be stored in a file named `AGENTS.md`.
|
||||
//! Currently, we include only the contents of the first file found as follows:
|
||||
//! Project-level documentation can be stored in files named `AGENTS.md`.
|
||||
//! We include the concatenation of all files found along the path from the
|
||||
//! repository root to the current working directory as follows:
|
||||
//!
|
||||
//! 1. Look for the doc file in the current working directory (as determined
|
||||
//! by the `Config`).
|
||||
//! 2. If not found, walk *upwards* until the Git repository root is reached
|
||||
//! (detected by the presence of a `.git` directory/file), or failing that,
|
||||
//! the filesystem root.
|
||||
//! 3. If the Git root is encountered, look for the doc file there. If it
|
||||
//! exists, the search stops – we do **not** walk past the Git root.
|
||||
//! 1. Determine the Git repository root by walking upwards from the current
|
||||
//! working directory until a `.git` directory or file is found. If no Git
|
||||
//! root is found, only the current working directory is considered.
|
||||
//! 2. Collect every `AGENTS.md` found from the repository root down to the
|
||||
//! current working directory (inclusive) and concatenate their contents in
|
||||
//! that order.
|
||||
//! 3. We do **not** walk past the Git root.
|
||||
|
||||
use crate::config::Config;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::error;
|
||||
|
||||
@@ -26,7 +27,7 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
match find_project_doc(config).await {
|
||||
match read_project_docs(config).await {
|
||||
Ok(Some(project_doc)) => match &config.user_instructions {
|
||||
Some(original_instructions) => Some(format!(
|
||||
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
|
||||
@@ -41,95 +42,135 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to locate and load the project documentation. Currently, the search
|
||||
/// starts from `Config::cwd`, but if we may want to consider other directories
|
||||
/// in the future, e.g., additional writable directories in the `SandboxPolicy`.
|
||||
/// Attempt to locate and load the project documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(contents))`. If no documentation file is found
|
||||
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
|
||||
/// `Err` so callers can decide how to handle them.
|
||||
async fn find_project_doc(config: &Config) -> std::io::Result<Option<String>> {
|
||||
let max_bytes = config.project_doc_max_bytes;
|
||||
/// On success returns `Ok(Some(contents))` where `contents` is the
|
||||
/// concatenation of all discovered docs. If no documentation file is found the
|
||||
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
|
||||
/// callers can decide how to handle them.
|
||||
pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String>> {
|
||||
let max_total = config.project_doc_max_bytes;
|
||||
|
||||
// Attempt to load from the working directory first.
|
||||
if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES, max_bytes).await? {
|
||||
return Ok(Some(doc));
|
||||
if max_total == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Walk up towards the filesystem root, stopping once we encounter the Git
|
||||
// repository root. The presence of **either** a `.git` *file* or
|
||||
// *directory* counts.
|
||||
let mut dir = config.cwd.clone();
|
||||
let paths = discover_project_doc_paths(config)?;
|
||||
if paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Canonicalize the path so that we do not end up in an infinite loop when
|
||||
// `cwd` contains `..` components.
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let file = match tokio::fs::File::open(&p).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let size = file.metadata().await?.len();
|
||||
let mut reader = tokio::io::BufReader::new(file).take(remaining);
|
||||
let mut data: Vec<u8> = Vec::new();
|
||||
reader.read_to_end(&mut data).await?;
|
||||
|
||||
if size > remaining {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
|
||||
p.display(),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(text);
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(parts.join("\n\n")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the list of AGENTS.md files using the same search rules as
|
||||
/// `read_project_docs`, but return the file paths instead of concatenated
|
||||
/// contents. The list is ordered from repository root to the current working
|
||||
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
|
||||
/// is zero, returns an empty list.
|
||||
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
|
||||
let mut dir = config.cwd.clone();
|
||||
if let Ok(canon) = dir.canonicalize() {
|
||||
dir = canon;
|
||||
}
|
||||
|
||||
while let Some(parent) = dir.parent() {
|
||||
// `.git` can be a *file* (for worktrees or submodules) or a *dir*.
|
||||
let git_marker = dir.join(".git");
|
||||
let git_exists = match tokio::fs::metadata(&git_marker).await {
|
||||
// Build chain from cwd upwards and detect git root.
|
||||
let mut chain: Vec<PathBuf> = vec![dir.clone()];
|
||||
let mut git_root: Option<PathBuf> = None;
|
||||
let mut cursor = dir.clone();
|
||||
while let Some(parent) = cursor.parent() {
|
||||
let git_marker = cursor.join(".git");
|
||||
let git_exists = match std::fs::metadata(&git_marker) {
|
||||
Ok(_) => true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
if git_exists {
|
||||
// We are at the repo root – attempt one final load.
|
||||
if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES, max_bytes).await? {
|
||||
return Ok(Some(doc));
|
||||
}
|
||||
git_root = Some(cursor.clone());
|
||||
break;
|
||||
}
|
||||
|
||||
dir = parent.to_path_buf();
|
||||
chain.push(parent.to_path_buf());
|
||||
cursor = parent.to_path_buf();
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Attempt to load the first candidate file found in `dir`. Returns the file
|
||||
/// contents (truncated if it exceeds `max_bytes`) when successful.
|
||||
async fn load_first_candidate(
|
||||
dir: &Path,
|
||||
names: &[&str],
|
||||
max_bytes: usize,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
for name in names {
|
||||
let candidate = dir.join(name);
|
||||
|
||||
let file = match tokio::fs::File::open(&candidate).await {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e),
|
||||
Ok(f) => f,
|
||||
};
|
||||
|
||||
let size = file.metadata().await?.len();
|
||||
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes));
|
||||
let mut limited = reader.take(max_bytes as u64);
|
||||
limited.read_to_end(&mut data).await?;
|
||||
|
||||
if size as usize > max_bytes {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds {max_bytes} bytes - truncating.",
|
||||
candidate.display(),
|
||||
);
|
||||
let search_dirs: Vec<PathBuf> = if let Some(root) = git_root {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
let mut saw_root = false;
|
||||
for p in chain.iter().rev() {
|
||||
if !saw_root {
|
||||
if p == &root {
|
||||
saw_root = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
dirs.push(p.clone());
|
||||
}
|
||||
dirs
|
||||
} else {
|
||||
vec![config.cwd.clone()]
|
||||
};
|
||||
|
||||
let contents = String::from_utf8_lossy(&data).to_string();
|
||||
if contents.trim().is_empty() {
|
||||
// Empty file – treat as not found.
|
||||
continue;
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
for d in search_dirs {
|
||||
for name in CANDIDATE_FILENAMES {
|
||||
let candidate = d.join(name);
|
||||
match std::fs::symlink_metadata(&candidate) {
|
||||
Ok(md) => {
|
||||
let ft = md.file_type();
|
||||
// Allow regular files and symlinks; opening will later fail for dangling links.
|
||||
if ft.is_file() || ft.is_symlink() {
|
||||
found.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(contents));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -278,4 +319,32 @@ mod tests {
|
||||
|
||||
assert_eq!(res, Some(INSTRUCTIONS.to_string()));
|
||||
}
|
||||
|
||||
/// When both the repository root and the working directory contain
|
||||
/// AGENTS.md files, their contents are concatenated from root to cwd.
|
||||
#[tokio::test]
|
||||
async fn concatenates_root_and_cwd_docs() {
|
||||
let repo = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
// Simulate a git repository.
|
||||
std::fs::write(
|
||||
repo.path().join(".git"),
|
||||
"gitdir: /path/to/actual/git/dir\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Repo root doc.
|
||||
fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap();
|
||||
|
||||
// Nested working directory with its own doc.
|
||||
let nested = repo.path().join("workspace/crate_a");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
fs::write(nested.join("AGENTS.md"), "crate doc").unwrap();
|
||||
|
||||
let mut cfg = make_config(&repo, 4096, None);
|
||||
cfg.cwd = nested;
|
||||
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use uuid::Uuid;
|
||||
use crate::config::Config;
|
||||
use crate::git_info::GitInfo;
|
||||
use crate::git_info::collect_git_info;
|
||||
use crate::models::ResponseItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
const SESSIONS_SUBDIR: &str = "sessions";
|
||||
|
||||
@@ -132,6 +132,8 @@ impl RolloutRecorder {
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
|
||||
ResponseItem::Other => {
|
||||
// These should never be serialized.
|
||||
@@ -194,6 +196,8 @@ impl RolloutRecorder {
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::Reasoning { .. } => items.push(item),
|
||||
ResponseItem::Other => {}
|
||||
},
|
||||
@@ -317,6 +321,8 @@ async fn rollout_writer(
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::Reasoning { .. } => {
|
||||
writer.write_line(&item).await?;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use shlex;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct ZshShell {
|
||||
shell_path: String,
|
||||
zshrc_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct PowerShellConfig {
|
||||
exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
|
||||
bash_exe_fallback: Option<PathBuf>, // In case the model generates a bash command.
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub enum Shell {
|
||||
Zsh(ZshShell),
|
||||
PowerShell(PowerShellConfig),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -33,6 +43,61 @@ impl Shell {
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
Shell::PowerShell(ps) => {
|
||||
// If model generated a bash command, prefer a detected bash fallback
|
||||
if let Some(script) = strip_bash_lc(&command) {
|
||||
return match &ps.bash_exe_fallback {
|
||||
Some(bash) => Some(vec![
|
||||
bash.to_string_lossy().to_string(),
|
||||
"-lc".to_string(),
|
||||
script,
|
||||
]),
|
||||
|
||||
// No bash fallback → run the script under PowerShell.
|
||||
// It will likely fail (except for some simple commands), but the error
|
||||
// should give a clue to the model to fix upon retry that it's running under PowerShell.
|
||||
None => Some(vec![
|
||||
ps.exe.clone(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// Not a bash command. If model did not generate a PowerShell command,
|
||||
// turn it into a PowerShell command.
|
||||
let first = command.first().map(String::as_str);
|
||||
if first != Some(ps.exe.as_str()) {
|
||||
// TODO (CODEX_2900): Handle escaping newlines.
|
||||
if command.iter().any(|a| a.contains('\n') || a.contains('\r')) {
|
||||
return Some(command);
|
||||
}
|
||||
|
||||
let joined = shlex::try_join(command.iter().map(|s| s.as_str())).ok();
|
||||
return joined.map(|arg| {
|
||||
vec![
|
||||
ps.exe.clone(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
arg,
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Model generated a PowerShell command. Run it.
|
||||
Some(command)
|
||||
}
|
||||
Shell::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
Shell::PowerShell(ps) => Some(ps.exe.clone()),
|
||||
Shell::Unknown => None,
|
||||
}
|
||||
}
|
||||
@@ -86,11 +151,51 @@ pub async fn default_user_shell() -> Shell {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
pub async fn default_user_shell() -> Shell {
|
||||
Shell::Unknown
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub async fn default_user_shell() -> Shell {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Prefer PowerShell 7+ (`pwsh`) if available, otherwise fall back to Windows PowerShell.
|
||||
let has_pwsh = Command::new("pwsh")
|
||||
.arg("-NoLogo")
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg("$PSVersionTable.PSVersion.Major")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
let bash_exe = if Command::new("bash.exe")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
which::which("bash.exe").ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if has_pwsh {
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: bash_exe,
|
||||
})
|
||||
} else {
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "powershell.exe".to_string(),
|
||||
bash_exe_fallback: bash_exe,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod tests {
|
||||
@@ -231,3 +336,97 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "windows")]
|
||||
mod tests_windows {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_default_shell_invocation_powershell() {
|
||||
let cases = vec![
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: None,
|
||||
}),
|
||||
vec!["bash", "-lc", "echo hello"],
|
||||
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"],
|
||||
),
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "powershell.exe".to_string(),
|
||||
bash_exe_fallback: None,
|
||||
}),
|
||||
vec!["bash", "-lc", "echo hello"],
|
||||
vec!["powershell.exe", "-NoProfile", "-Command", "echo hello"],
|
||||
),
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec!["bash", "-lc", "echo hello"],
|
||||
vec!["bash.exe", "-lc", "echo hello"],
|
||||
),
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec![
|
||||
"bash",
|
||||
"-lc",
|
||||
"apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF",
|
||||
],
|
||||
vec![
|
||||
"bash.exe",
|
||||
"-lc",
|
||||
"apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF",
|
||||
],
|
||||
),
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec!["echo", "hello"],
|
||||
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"],
|
||||
),
|
||||
(
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"],
|
||||
vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"],
|
||||
),
|
||||
(
|
||||
// TODO (CODEX_2900): Handle escaping newlines for powershell invocation.
|
||||
Shell::PowerShell(PowerShellConfig {
|
||||
exe: "powershell.exe".to_string(),
|
||||
bash_exe_fallback: Some(PathBuf::from("bash.exe")),
|
||||
}),
|
||||
vec![
|
||||
"codex-mcp-server.exe",
|
||||
"--codex-run-as-apply-patch",
|
||||
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
|
||||
],
|
||||
vec![
|
||||
"codex-mcp-server.exe",
|
||||
"--codex-run-as-apply-patch",
|
||||
"*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
for (shell, input, expected_cmd) in cases {
|
||||
let actual_cmd = shell
|
||||
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
|
||||
assert_eq!(
|
||||
actual_cmd,
|
||||
Some(expected_cmd.iter().map(|s| s.to_string()).collect())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
codex-rs/core/src/subagents/definition.rs
Normal file
32
codex-rs/core/src/subagents/definition.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubagentDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Base instructions for this subagent.
|
||||
pub instructions: String,
|
||||
/// When not set, inherits the parent agent's tool set. When set to an
|
||||
/// empty list, no tools are available to the subagent.
|
||||
#[serde(default)]
|
||||
pub tools: Option<Vec<String>>, // None => inherit; Some(vec) => allow-list
|
||||
}
|
||||
|
||||
impl SubagentDefinition {
|
||||
pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str::<Self>(s)
|
||||
}
|
||||
|
||||
pub fn from_file(path: &Path) -> std::io::Result<Self> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
// Surface JSON parsing error with file context
|
||||
serde_json::from_str::<Self>(&contents).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("invalid subagent JSON at {}: {e}", path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
6
codex-rs/core/src/subagents/mod.rs
Normal file
6
codex-rs/core/src/subagents/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod definition;
|
||||
pub mod registry;
|
||||
pub mod runner;
|
||||
pub mod tool;
|
||||
|
||||
pub(crate) use tool::{SUBAGENT_LIST_TOOL, SUBAGENT_TOOL};
|
||||
92
codex-rs/core/src/subagents/registry.rs
Normal file
92
codex-rs/core/src/subagents/registry.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use super::definition::SubagentDefinition;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SubagentRegistry {
|
||||
/// Directory under the project (cwd/.codex/agents).
|
||||
project_dir: Option<PathBuf>,
|
||||
/// Directory under CODEX_HOME (~/.codex/agents).
|
||||
user_dir: Option<PathBuf>,
|
||||
/// Merged map: project definitions override user ones.
|
||||
map: HashMap<String, SubagentDefinition>,
|
||||
}
|
||||
|
||||
impl SubagentRegistry {
|
||||
pub fn new(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
project_dir,
|
||||
user_dir,
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads JSON files from user_dir then project_dir (project wins on conflict).
|
||||
pub fn load(&mut self) {
|
||||
let mut map: HashMap<String, SubagentDefinition> = HashMap::new();
|
||||
|
||||
// Load user definitions first
|
||||
if let Some(dir) = &self.user_dir {
|
||||
Self::load_from_dir_into(dir, &mut map);
|
||||
}
|
||||
// Then load project definitions which override on conflicts
|
||||
if let Some(dir) = &self.project_dir {
|
||||
Self::load_from_dir_into(dir, &mut map);
|
||||
}
|
||||
|
||||
// Ensure a simple built‑in test subagent exists to validate wiring end‑to‑end.
|
||||
// Users can override this by providing their own definition named "hello".
|
||||
if !map.contains_key("hello") {
|
||||
map.insert(
|
||||
"hello".to_string(),
|
||||
SubagentDefinition {
|
||||
name: "hello".to_string(),
|
||||
description: "Built‑in test subagent that replies with a greeting".to_string(),
|
||||
// Keep instructions narrow so models reliably output the intended text.
|
||||
instructions:
|
||||
"Reply with exactly this text and nothing else: Hello from subagent"
|
||||
.to_string(),
|
||||
// Disallow tool usage for the hello subagent.
|
||||
tools: Some(Vec::new()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.map = map;
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&SubagentDefinition> {
|
||||
self.map.get(name)
|
||||
}
|
||||
|
||||
pub fn all_names(&self) -> Vec<String> {
|
||||
self.map.keys().cloned().collect()
|
||||
}
|
||||
|
||||
fn load_from_dir_into(dir: &Path, out: &mut HashMap<String, SubagentDefinition>) {
|
||||
let Ok(iter) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in iter.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("json"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
match SubagentDefinition::from_file(&path) {
|
||||
Ok(def) => {
|
||||
out.insert(def.name.clone(), def);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load subagent from {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
codex-rs/core/src/subagents/runner.rs
Normal file
142
codex-rs/core/src/subagents/runner.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::codex::Codex;
|
||||
use crate::error::Result as CodexResult;
|
||||
|
||||
use super::definition::SubagentDefinition;
|
||||
use super::registry::SubagentRegistry;
|
||||
|
||||
/// Arguments expected for the `subagent.run` tool.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RunSubagentArgs {
|
||||
pub name: String,
|
||||
pub input: String,
|
||||
#[serde(default)]
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
/// Run a subagent in a nested Codex session and return the final message.
|
||||
pub(crate) async fn run(
|
||||
sess: &crate::codex::Session,
|
||||
turn_context: &crate::codex::TurnContext,
|
||||
registry: &SubagentRegistry,
|
||||
args: RunSubagentArgs,
|
||||
_parent_sub_id: &str,
|
||||
) -> CodexResult<String> {
|
||||
let def: &SubagentDefinition = registry.get(&args.name).ok_or_else(|| {
|
||||
crate::error::CodexErr::Stream(format!("unknown subagent: {}", args.name), None)
|
||||
})?;
|
||||
|
||||
let mut nested_cfg = (*sess.base_config()).clone();
|
||||
nested_cfg.base_instructions = Some(def.instructions.clone());
|
||||
nested_cfg.user_instructions = None;
|
||||
nested_cfg.approval_policy = turn_context.approval_policy;
|
||||
nested_cfg.sandbox_policy = turn_context.sandbox_policy.clone();
|
||||
nested_cfg.cwd = turn_context.cwd.clone();
|
||||
nested_cfg.include_subagent_tool = false;
|
||||
|
||||
let nested = Codex::spawn(nested_cfg, sess.auth_manager(), None).await?;
|
||||
let nested_codex = nested.codex;
|
||||
|
||||
let subagent_id = uuid::Uuid::new_v4().to_string();
|
||||
forward_begin(sess, _parent_sub_id, &subagent_id, &def.name).await;
|
||||
|
||||
let text = match args.context {
|
||||
Some(ctx) if !ctx.trim().is_empty() => format!("{ctx}\n\n{input}", input = args.input),
|
||||
_ => args.input,
|
||||
};
|
||||
|
||||
nested_codex
|
||||
.submit(crate::protocol::Op::UserInput {
|
||||
items: vec![crate::protocol::InputItem::Text { text }],
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::CodexErr::Stream(format!("failed to submit to subagent: {e}"), None)
|
||||
})?;
|
||||
|
||||
let mut last_message: Option<String> = None;
|
||||
loop {
|
||||
let ev = nested_codex.next_event().await?;
|
||||
match ev.msg.clone() {
|
||||
crate::protocol::EventMsg::AgentMessage(m) => {
|
||||
last_message = Some(m.message);
|
||||
}
|
||||
crate::protocol::EventMsg::TaskComplete(t) => {
|
||||
let _ = nested_codex.submit(crate::protocol::Op::Shutdown).await;
|
||||
forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await;
|
||||
forward_end(
|
||||
sess,
|
||||
_parent_sub_id,
|
||||
&subagent_id,
|
||||
&def.name,
|
||||
true,
|
||||
t.last_agent_message.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(t
|
||||
.last_agent_message
|
||||
.unwrap_or_else(|| last_message.unwrap_or_default()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
forward_forwarded(sess, _parent_sub_id, &subagent_id, &def.name, ev.msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_begin(
|
||||
sess: &crate::codex::Session,
|
||||
parent_sub_id: &str,
|
||||
subagent_id: &str,
|
||||
name: &str,
|
||||
) {
|
||||
sess
|
||||
.send_event(crate::protocol::Event {
|
||||
id: parent_sub_id.to_string(),
|
||||
msg: crate::protocol::EventMsg::SubagentBegin(crate::protocol::SubagentBeginEvent {
|
||||
subagent_id: subagent_id.to_string(),
|
||||
name: name.to_string(),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn forward_forwarded(
|
||||
sess: &crate::codex::Session,
|
||||
parent_sub_id: &str,
|
||||
subagent_id: &str,
|
||||
name: &str,
|
||||
msg: crate::protocol::EventMsg,
|
||||
) {
|
||||
sess
|
||||
.send_event(crate::protocol::Event {
|
||||
id: parent_sub_id.to_string(),
|
||||
msg: crate::protocol::EventMsg::SubagentForwarded(
|
||||
crate::protocol::SubagentForwardedEvent {
|
||||
subagent_id: subagent_id.to_string(),
|
||||
name: name.to_string(),
|
||||
event: Box::new(msg),
|
||||
},
|
||||
),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn forward_end(
|
||||
sess: &crate::codex::Session,
|
||||
parent_sub_id: &str,
|
||||
subagent_id: &str,
|
||||
name: &str,
|
||||
success: bool,
|
||||
last_agent_message: Option<String>,
|
||||
) {
|
||||
sess
|
||||
.send_event(crate::protocol::Event {
|
||||
id: parent_sub_id.to_string(),
|
||||
msg: crate::protocol::EventMsg::SubagentEnd(crate::protocol::SubagentEndEvent {
|
||||
subagent_id: subagent_id.to_string(),
|
||||
name: name.to_string(),
|
||||
success,
|
||||
last_agent_message,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
54
codex-rs/core/src/subagents/tool.rs
Normal file
54
codex-rs/core/src/subagents/tool.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::openai_tools::JsonSchema;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::openai_tools::ResponsesApiTool;
|
||||
|
||||
pub(crate) static SUBAGENT_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"name".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Registered subagent name".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"input".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Task or instruction for the subagent".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"context".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Optional extra context to aid the task".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "subagent_run".to_string(),
|
||||
description: "Invoke a named subagent with isolated context and return its result"
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["name".to_string(), "input".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
pub(crate) static SUBAGENT_LIST_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
|
||||
let properties = BTreeMap::new();
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "subagent_list".to_string(),
|
||||
description: "List available subagents (name and description). Call before subagent_run if unsure.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
});
|
||||
72
codex-rs/core/src/terminal.rs
Normal file
72
codex-rs/core/src/terminal.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static TERMINAL: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn user_agent() -> String {
|
||||
TERMINAL.get_or_init(detect_terminal).to_string()
|
||||
}
|
||||
|
||||
/// Sanitize a header value to be used in a User-Agent string.
|
||||
///
|
||||
/// This function replaces any characters that are not allowed in a User-Agent string with an underscore.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The value to sanitize.
|
||||
fn is_valid_header_value_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/'
|
||||
}
|
||||
|
||||
fn sanitize_header_value(value: String) -> String {
|
||||
value.replace(|c| !is_valid_header_value_char(c), "_")
|
||||
}
|
||||
|
||||
fn detect_terminal() -> String {
|
||||
sanitize_header_value(
|
||||
if let Ok(tp) = std::env::var("TERM_PROGRAM")
|
||||
&& !tp.trim().is_empty()
|
||||
{
|
||||
let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
|
||||
match ver {
|
||||
Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
|
||||
_ => tp,
|
||||
}
|
||||
} else if let Ok(v) = std::env::var("WEZTERM_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("WezTerm/{v}")
|
||||
} else {
|
||||
"WezTerm".to_string()
|
||||
}
|
||||
} else if std::env::var("KITTY_WINDOW_ID").is_ok()
|
||||
|| std::env::var("TERM")
|
||||
.map(|t| t.contains("kitty"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"kitty".to_string()
|
||||
} else if std::env::var("ALACRITTY_SOCKET").is_ok()
|
||||
|| std::env::var("TERM")
|
||||
.map(|t| t == "alacritty")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"Alacritty".to_string()
|
||||
} else if let Ok(v) = std::env::var("KONSOLE_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("Konsole/{v}")
|
||||
} else {
|
||||
"Konsole".to_string()
|
||||
}
|
||||
} else if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() {
|
||||
return "gnome-terminal".to_string();
|
||||
} else if let Ok(v) = std::env::var("VTE_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("VTE/{v}")
|
||||
} else {
|
||||
"VTE".to_string()
|
||||
}
|
||||
} else if std::env::var("WT_SESSION").is_ok() {
|
||||
return "WindowsTerminal".to_string();
|
||||
} else {
|
||||
std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string())
|
||||
},
|
||||
)
|
||||
}
|
||||
145
codex-rs/core/src/tool_apply_patch.rs
Normal file
145
codex-rs/core/src/tool_apply_patch.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::openai_tools::FreeformTool;
|
||||
use crate::openai_tools::FreeformToolFormat;
|
||||
use crate::openai_tools::JsonSchema;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::openai_tools::ResponsesApiTool;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct ApplyPatchToolArgs {
|
||||
pub(crate) input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApplyPatchToolType {
|
||||
Freeform,
|
||||
Function,
|
||||
}
|
||||
|
||||
/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
|
||||
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
|
||||
pub(crate) fn create_apply_patch_freeform_tool() -> OpenAiTool {
|
||||
OpenAiTool::Freeform(FreeformTool {
|
||||
name: "apply_patch".to_string(),
|
||||
description: "Use the `apply_patch` tool to edit files".to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: r#"start: begin_patch hunk+ end_patch
|
||||
begin_patch: "*** Begin Patch" LF
|
||||
end_patch: "*** End Patch" LF?
|
||||
|
||||
hunk: add_hunk | delete_hunk | update_hunk
|
||||
add_hunk: "*** Add File: " filename LF add_line+
|
||||
delete_hunk: "*** Delete File: " filename LF
|
||||
update_hunk: "*** Update File: " filename LF change_move? change?
|
||||
|
||||
filename: /(.+)/
|
||||
add_line: "+" /(.+)/ LF -> line
|
||||
|
||||
change_move: "*** Move to: " filename LF
|
||||
change: (change_context | change_line)+ eof_line?
|
||||
change_context: ("@@" | "@@ " /(.+)/) LF
|
||||
change_line: ("+" | "-" | " ") /(.+)/ LF
|
||||
eof_line: "*** End of File" LF
|
||||
|
||||
%import common.LF
|
||||
"#
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models
|
||||
pub(crate) fn create_apply_patch_json_tool() -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"input".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "apply_patch".to_string(),
|
||||
description: r#"Use the `apply_patch` tool to edit files.
|
||||
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
*** Begin Patch
|
||||
[ one or more file sections ]
|
||||
*** End Patch
|
||||
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
|
||||
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
*** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
*** Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
May be immediately followed by *** Move to: <new path> if you want to rename the file.
|
||||
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
|
||||
Within a hunk each line starts with:
|
||||
|
||||
For instructions on [context_before] and [context_after]:
|
||||
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
|
||||
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
|
||||
@@ class BaseClass
|
||||
[3 lines of pre-context]
|
||||
- [old_code]
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
|
||||
|
||||
@@ class BaseClass
|
||||
@@ def method():
|
||||
[3 lines of pre-context]
|
||||
- [old_code]
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
The full grammar definition is below:
|
||||
Patch := Begin { FileOp } End
|
||||
Begin := "*** Begin Patch" NEWLINE
|
||||
End := "*** End Patch" NEWLINE
|
||||
FileOp := AddFile | DeleteFile | UpdateFile
|
||||
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
|
||||
DeleteFile := "*** Delete File: " path NEWLINE
|
||||
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
|
||||
MoveTo := "*** Move to: " newPath NEWLINE
|
||||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||||
|
||||
A full patch can combine several operations:
|
||||
|
||||
*** Begin Patch
|
||||
*** Add File: hello.txt
|
||||
+Hello world
|
||||
*** Update File: src/app.py
|
||||
*** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
*** Delete File: obsolete.txt
|
||||
*** End Patch
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
- File references can only be relative, NEVER ABSOLUTE.
|
||||
"#
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["input".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,12 @@ 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} ({} {}; {})",
|
||||
"{}/{build_version} ({} {}; {}) {}",
|
||||
originator.unwrap_or(DEFAULT_ORIGINATOR),
|
||||
os_info.os_type(),
|
||||
os_info.version(),
|
||||
os_info.architecture().unwrap_or("unknown"),
|
||||
crate::terminal::user_agent()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,9 +28,10 @@ mod tests {
|
||||
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();
|
||||
let re = Regex::new(
|
||||
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(re.is_match(&user_agent));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,13 +142,14 @@ 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 conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
conversation_id,
|
||||
session_configured: _,
|
||||
} = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
@@ -207,9 +208,10 @@ async fn includes_base_instructions_override_in_request() {
|
||||
config.base_instructions = Some("test instructions".to_string());
|
||||
config.model_provider = model_provider;
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -260,11 +262,12 @@ async fn originator_config_override_is_used() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.internal_originator = Some("my_override".to_string());
|
||||
config.responses_originator_header = "my_override".to_owned();
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -318,13 +321,13 @@ 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 conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
conversation_id,
|
||||
session_configured: _,
|
||||
} = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
@@ -411,7 +414,13 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
|
||||
config.model_provider = model_provider;
|
||||
config.preferred_auth_method = AuthMode::ChatGPT;
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let auth_manager =
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {}", e),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
..
|
||||
@@ -486,7 +495,13 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
config.model_provider = model_provider;
|
||||
config.preferred_auth_method = AuthMode::ApiKey;
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let auth_manager =
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {}", e),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
..
|
||||
@@ -540,9 +555,10 @@ async fn includes_user_instructions_message_in_request() {
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -632,9 +648,9 @@ 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 conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, None)
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -708,9 +724,9 @@ async fn env_var_overrides_loaded_auth() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth());
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(create_dummy_codex_auth()))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
@@ -141,9 +141,9 @@ 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 conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("dummy")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.unwrap()
|
||||
.conversation;
|
||||
|
||||
@@ -70,12 +70,12 @@ async fn truncates_output_lines() {
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
|
||||
let expected_output = (1..=256)
|
||||
let expected_output = (1..=300)
|
||||
.map(|i| format!("{i}\n"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert_eq!(output.stdout.text, expected_output);
|
||||
assert_eq!(output.stdout.truncated_after_lines, Some(256));
|
||||
assert_eq!(output.stdout.truncated_after_lines, None);
|
||||
}
|
||||
|
||||
/// Command succeeds with exit code 0 normally
|
||||
@@ -91,8 +91,8 @@ async fn truncates_output_bytes() {
|
||||
|
||||
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));
|
||||
assert!(output.stdout.text.len() >= 15000);
|
||||
assert_eq!(output.stdout.truncated_after_lines, None);
|
||||
}
|
||||
|
||||
/// Command not found returns exit code 127, this is not considered a sandbox error
|
||||
|
||||
@@ -139,3 +139,34 @@ async fn test_exec_stderr_stream_events_echo() {
|
||||
}
|
||||
assert_eq!(String::from_utf8_lossy(&err), "oops\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_aggregated_output_interleaves_in_order() {
|
||||
// Spawn a shell that alternates stdout and stderr with sleeps to enforce order.
|
||||
let cmd = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(),
|
||||
];
|
||||
|
||||
let params = ExecParams {
|
||||
command: cmd,
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
timeout_ms: Some(5_000),
|
||||
env: HashMap::new(),
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
};
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None)
|
||||
.await
|
||||
.expect("process_exec_tool_call");
|
||||
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert_eq!(result.stdout.text, "O1\nO2\n");
|
||||
assert_eq!(result.stderr.text, "E1\nE2\n");
|
||||
assert_eq!(result.aggregated_output.text, "O1\nE1\nO2\nE2\n");
|
||||
assert_eq!(result.aggregated_output.truncated_after_lines, None);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
@@ -8,6 +11,7 @@ use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_core::shell::default_user_shell;
|
||||
use codex_login::CodexAuth;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
@@ -24,6 +28,185 @@ fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
|
||||
assert_eq!(
|
||||
body["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
expected_names
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn codex_mini_latest_tools() {
|
||||
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::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
config.include_apply_patch_tool = false;
|
||||
config.model = "codex-mini-latest".to_string();
|
||||
config.model_family = find_family_for_model("codex-mini-latest").unwrap();
|
||||
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.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_instructions = [
|
||||
include_str!("../prompt.md"),
|
||||
include_str!("../../apply-patch/apply_patch_tool_instructions.md"),
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body0["instructions"],
|
||||
serde_json::json!(expected_instructions),
|
||||
);
|
||||
let body1 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body1["instructions"],
|
||||
serde_json::json!(expected_instructions),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn prompt_tools_are_consistent_across_requests() {
|
||||
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());
|
||||
config.include_apply_patch_tool = true;
|
||||
config.include_plan_tool = true;
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.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_instructions: &str = include_str!("../prompt.md");
|
||||
// our internal implementation is responsible for keeping tools in sync
|
||||
// with the OpenAI schema, so we just verify the tool presence here
|
||||
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch"];
|
||||
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body0["instructions"],
|
||||
serde_json::json!(expected_instructions),
|
||||
);
|
||||
assert_tool_names(&body0, expected_tools_names);
|
||||
|
||||
let body1 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body1["instructions"],
|
||||
serde_json::json!(expected_instructions),
|
||||
);
|
||||
assert_tool_names(&body1, expected_tools_names);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prefixes_context_and_instructions_once_and_consistently_across_requests() {
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -55,9 +238,10 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -85,9 +269,20 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2, "expected two POST requests");
|
||||
|
||||
let shell = default_user_shell().await;
|
||||
|
||||
let expected_env_text = format!(
|
||||
"<environment_context>\nCurrent working directory: {}\nApproval policy: on-request\nSandbox mode: read-only\nNetwork access: restricted\n</environment_context>",
|
||||
cwd.path().to_string_lossy()
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
{}</environment_context>"#,
|
||||
cwd.path().to_string_lossy(),
|
||||
match shell.name() {
|
||||
Some(name) => format!(" <shell>{}</shell>\n", name),
|
||||
None => String::new(),
|
||||
}
|
||||
);
|
||||
let expected_ui_text =
|
||||
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
|
||||
@@ -165,9 +360,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
@@ -183,12 +379,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Change everything about the turn context.
|
||||
let new_cwd = TempDir::new().unwrap();
|
||||
let writable = TempDir::new().unwrap();
|
||||
codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: Some(new_cwd.path().to_path_buf()),
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![writable.path().to_path_buf()],
|
||||
@@ -220,7 +414,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
|
||||
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let body2 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
// prompt_cache_key should remain constant across overrides
|
||||
assert_eq!(
|
||||
body1["prompt_cache_key"], body2["prompt_cache_key"],
|
||||
@@ -236,11 +429,13 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
// After overriding the turn context, the environment context should be emitted again
|
||||
// reflecting the new cwd, approval policy and sandbox settings.
|
||||
let expected_env_text_2 = format!(
|
||||
"<environment_context>\nCurrent working directory: {}\nApproval policy: never\nSandbox mode: workspace-write\nNetwork access: enabled\n</environment_context>",
|
||||
new_cwd.path().to_string_lossy()
|
||||
);
|
||||
// reflecting the new approval policy and sandbox settings. Omit cwd because it did
|
||||
// not change.
|
||||
let expected_env_text_2 = r#"<environment_context>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
</environment_context>"#;
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
@@ -288,9 +483,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
@@ -88,9 +88,10 @@ async fn continue_after_stream_error() {
|
||||
config.base_instructions = Some("You are a helpful assistant".to_string());
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.unwrap()
|
||||
.conversation;
|
||||
|
||||
@@ -93,9 +93,10 @@ async fn retries_on_early_close() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation_with_auth(config, Some(CodexAuth::from_api_key("Test API Key")))
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.unwrap()
|
||||
.conversation;
|
||||
|
||||
@@ -25,6 +25,7 @@ codex-common = { path = "../common", features = [
|
||||
"sandbox_summary",
|
||||
] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-ollama = { path = "../ollama" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
owo-colors = "4.2.0"
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
@@ -167,6 +168,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
fn process_event(&mut self, event: Event) -> CodexStatus {
|
||||
let Event { id: _, msg } = event;
|
||||
match msg {
|
||||
EventMsg::SubagentBegin(_) => {
|
||||
// Ignore in human output for now.
|
||||
}
|
||||
EventMsg::SubagentForwarded(_) => {
|
||||
// Ignore; TUI will render forwarded events.
|
||||
}
|
||||
EventMsg::SubagentEnd(_) => {
|
||||
// Ignore in human output for now.
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
let prefix = "ERROR:".style(self.red);
|
||||
ts_println!(self, "{prefix} {message}");
|
||||
@@ -174,6 +184,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
ts_println!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => {
|
||||
ts_println!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
// Ignore.
|
||||
}
|
||||
@@ -283,10 +296,10 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
EventMsg::ExecCommandOutputDelta(_) => {}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
duration,
|
||||
exit_code,
|
||||
..
|
||||
}) => {
|
||||
let exec_command = self.call_id_to_command.remove(&call_id);
|
||||
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
|
||||
@@ -299,8 +312,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
("".to_string(), format!("exec('{call_id}')"))
|
||||
};
|
||||
|
||||
let output = if exit_code == 0 { stdout } else { stderr };
|
||||
let truncated_output = output
|
||||
let truncated_output = aggregated_output
|
||||
.lines()
|
||||
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
|
||||
.collect::<Vec<_>>()
|
||||
@@ -535,6 +547,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
},
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::ConversationHistory(_) => {}
|
||||
}
|
||||
CodexStatus::Running
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ impl EventProcessor for EventProcessorWithJsonOutput {
|
||||
|
||||
fn process_event(&mut self, event: Event) -> CodexStatus {
|
||||
match event.msg {
|
||||
EventMsg::SubagentBegin(_)
|
||||
| EventMsg::SubagentForwarded(_)
|
||||
| EventMsg::SubagentEnd(_) => {
|
||||
// Ignored for JSON output in exec for now.
|
||||
CodexStatus::Running
|
||||
}
|
||||
EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => {
|
||||
// Suppress streaming events in JSON mode.
|
||||
CodexStatus::Running
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use codex_login::AuthManager;
|
||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
@@ -145,6 +146,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
model_provider,
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
include_subagent_tool: None,
|
||||
include_plan_tool: None,
|
||||
include_apply_patch_tool: None,
|
||||
disable_response_storage: oss.then_some(true),
|
||||
@@ -185,7 +187,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let conversation_manager = ConversationManager::new(AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
config.preferred_auth_method,
|
||||
));
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
|
||||
@@ -123,6 +123,155 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> {
|
||||
// Start a mock model server
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// First response: model calls apply_patch to create test.md
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
|
||||
"text/event-stream",
|
||||
);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
// .and(path("/v1/responses"))
|
||||
.respond_with(first)
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Second response: model calls apply_patch to update test.md
|
||||
let second = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
|
||||
"text/event-stream",
|
||||
);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(second)
|
||||
.up_to_n_times(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let final_completed = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(
|
||||
load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
|
||||
"text/event-stream",
|
||||
);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(final_completed)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp_cwd = TempDir::new().unwrap();
|
||||
Command::cargo_bin("codex-exec")
|
||||
.context("should find binary for codex-exec")?
|
||||
.current_dir(tmp_cwd.path())
|
||||
.env("CODEX_HOME", tmp_cwd.path())
|
||||
.env("OPENAI_API_KEY", "dummy")
|
||||
.env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-s")
|
||||
.arg("workspace-write")
|
||||
.arg("foo")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Verify final file contents
|
||||
let final_path = tmp_cwd.path().join("test.md");
|
||||
let contents = std::fs::read_to_string(&final_path)
|
||||
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
|
||||
assert_eq!(contents, "Final text\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
|
||||
use core_test_support::load_sse_fixture_with_id_from_str;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const SSE_TOOL_CALL_ADD: &str = r#"[
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "custom_tool_call",
|
||||
"name": "apply_patch",
|
||||
"input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch",
|
||||
"call_id": "__ID__"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "__ID__",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": null,
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": 0
|
||||
},
|
||||
"output": []
|
||||
}
|
||||
}
|
||||
]"#;
|
||||
|
||||
const SSE_TOOL_CALL_UPDATE: &str = r#"[
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "custom_tool_call",
|
||||
"name": "apply_patch",
|
||||
"input": "*** Begin Patch\n*** Update File: test.md\n@@\n-Hello world\n+Final text\n*** End Patch",
|
||||
"call_id": "__ID__"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "__ID__",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": null,
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": 0
|
||||
},
|
||||
"output": []
|
||||
}
|
||||
}
|
||||
]"#;
|
||||
|
||||
const SSE_TOOL_CALL_COMPLETED: &str = r#"[
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "__ID__",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens_details": null,
|
||||
"output_tokens": 0,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": 0
|
||||
},
|
||||
"output": []
|
||||
}
|
||||
}
|
||||
]"#;
|
||||
|
||||
// Start a mock model server
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// First response: model calls apply_patch to create test.md
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
|
||||
@@ -26,7 +26,7 @@ multimap = "0.10.0"
|
||||
path-absolutize = "3.1.1"
|
||||
regex-lite = "0.1"
|
||||
serde = { version = "1.0.194", features = ["derive"] }
|
||||
serde_json = "1.0.142"
|
||||
serde_json = "1.0.143"
|
||||
serde_with = { version = "3", features = ["macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -17,5 +17,5 @@ clap = { version = "4", features = ["derive"] }
|
||||
ignore = "0.4.23"
|
||||
nucleo-matcher = "0.3.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.142"
|
||||
serde_json = "1.0.143"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -24,8 +24,8 @@ file-search *args:
|
||||
fmt:
|
||||
cargo fmt -- --config imports_granularity=Item
|
||||
|
||||
fix:
|
||||
cargo clippy --fix --all-features --tests --allow-dirty
|
||||
fix *args:
|
||||
cargo clippy --fix --all-features --tests --allow-dirty "$@"
|
||||
|
||||
install:
|
||||
rustup show active-toolchain
|
||||
|
||||
@@ -9,6 +9,7 @@ workspace = true
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
129
codex-rs/login/src/auth_manager.rs
Normal file
129
codex-rs/login/src/auth_manager.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::AuthMode;
|
||||
use crate::CodexAuth;
|
||||
|
||||
/// Internal cached auth state.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CachedAuth {
|
||||
preferred_auth_mode: AuthMode,
|
||||
auth: Option<CodexAuth>,
|
||||
}
|
||||
|
||||
/// Central manager providing a single source of truth for auth.json derived
|
||||
/// authentication data. It loads once (or on preference change) and then
|
||||
/// hands out cloned `CodexAuth` values so the rest of the program has a
|
||||
/// consistent snapshot.
|
||||
///
|
||||
/// External modifications to `auth.json` will NOT be observed until
|
||||
/// `reload()` is called explicitly. This matches the design goal of avoiding
|
||||
/// different parts of the program seeing inconsistent auth data mid‑run.
|
||||
#[derive(Debug)]
|
||||
pub struct AuthManager {
|
||||
codex_home: PathBuf,
|
||||
inner: RwLock<CachedAuth>,
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
/// Create a new manager loading the initial auth using the provided
|
||||
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
|
||||
/// simply return `None` in that case so callers can treat it as an
|
||||
/// unauthenticated state.
|
||||
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self {
|
||||
let auth = crate::CodexAuth::from_codex_home(&codex_home, preferred_auth_mode)
|
||||
.ok()
|
||||
.flatten();
|
||||
Self {
|
||||
codex_home,
|
||||
inner: RwLock::new(CachedAuth {
|
||||
preferred_auth_mode,
|
||||
auth,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an AuthManager with a specific CodexAuth, for testing only.
|
||||
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
|
||||
let preferred_auth_mode = auth.mode;
|
||||
let cached = CachedAuth {
|
||||
preferred_auth_mode,
|
||||
auth: Some(auth),
|
||||
};
|
||||
Arc::new(Self {
|
||||
codex_home: PathBuf::new(),
|
||||
inner: RwLock::new(cached),
|
||||
})
|
||||
}
|
||||
|
||||
/// Current cached auth (clone). May be `None` if not logged in or load failed.
|
||||
pub fn auth(&self) -> Option<CodexAuth> {
|
||||
self.inner.read().ok().and_then(|c| c.auth.clone())
|
||||
}
|
||||
|
||||
/// Preferred auth method used when (re)loading.
|
||||
pub fn preferred_auth_method(&self) -> AuthMode {
|
||||
self.inner
|
||||
.read()
|
||||
.map(|c| c.preferred_auth_mode)
|
||||
.unwrap_or(AuthMode::ApiKey)
|
||||
}
|
||||
|
||||
/// Force a reload using the existing preferred auth method. Returns
|
||||
/// whether the auth value changed.
|
||||
pub fn reload(&self) -> bool {
|
||||
let preferred = self.preferred_auth_method();
|
||||
let new_auth = crate::CodexAuth::from_codex_home(&self.codex_home, preferred)
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
|
||||
guard.auth = new_auth;
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn auths_equal(a: &Option<CodexAuth>, b: &Option<CodexAuth>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor returning an `Arc` wrapper.
|
||||
pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc<Self> {
|
||||
Arc::new(Self::new(codex_home, preferred_auth_mode))
|
||||
}
|
||||
|
||||
/// Attempt to refresh the current auth token (if any). On success, reload
|
||||
/// the auth state from disk so other components observe refreshed token.
|
||||
pub async fn refresh_token(&self) -> std::io::Result<Option<String>> {
|
||||
let auth = match self.auth() {
|
||||
Some(a) => a,
|
||||
None => return Ok(None),
|
||||
};
|
||||
match auth.refresh_token().await {
|
||||
Ok(token) => {
|
||||
// Reload to pick up persisted changes.
|
||||
self.reload();
|
||||
Ok(Some(token))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
|
||||
/// if a file was removed, Ok(false) if no auth file existed. On success,
|
||||
/// reloads the in‑memory auth cache so callers immediately observe the
|
||||
/// unauthenticated state.
|
||||
pub fn logout(&self) -> std::io::Result<bool> {
|
||||
let removed = crate::logout(&self.codex_home)?;
|
||||
// Always reload to clear any cached auth (even if file absent).
|
||||
self.reload();
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
@@ -23,19 +23,15 @@ pub use crate::server::run_login_server;
|
||||
pub use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_id_token;
|
||||
|
||||
mod auth_manager;
|
||||
mod pkce;
|
||||
mod server;
|
||||
mod token_data;
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Copy, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMode {
|
||||
ApiKey,
|
||||
ChatGPT,
|
||||
}
|
||||
pub use auth_manager::AuthManager;
|
||||
pub use codex_protocol::mcp_protocol::AuthMode;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAuth {
|
||||
@@ -62,6 +58,39 @@ impl CodexAuth {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
|
||||
let token_data = self
|
||||
.get_current_token_data()
|
||||
.ok_or(std::io::Error::other("Token data is not available."))?;
|
||||
let token = token_data.refresh_token;
|
||||
|
||||
let refresh_response = try_refresh_token(token)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let updated = update_tokens(
|
||||
&self.auth_file,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Ok(mut auth_lock) = self.auth_dot_json.lock() {
|
||||
*auth_lock = Some(updated.clone());
|
||||
}
|
||||
|
||||
let access = match updated.tokens {
|
||||
Some(t) => t.access_token,
|
||||
None => {
|
||||
return Err(std::io::Error::other(
|
||||
"Token data is not available after refresh.",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Loads the available auth information from the auth.json or
|
||||
/// OPENAI_API_KEY environment variable.
|
||||
pub fn from_codex_home(
|
||||
@@ -209,7 +238,7 @@ fn load_auth(
|
||||
// "refreshable" even if we are using the API key for auth?
|
||||
match &tokens {
|
||||
Some(tokens) => {
|
||||
if tokens.should_use_api_key(preferred_auth_method) {
|
||||
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
} else {
|
||||
// Ignore the API key and fall through to ChatGPT auth.
|
||||
|
||||
@@ -25,16 +25,31 @@ pub struct TokenData {
|
||||
impl TokenData {
|
||||
/// Returns true if this is a plan that should use the traditional
|
||||
/// "metered" billing via an API key.
|
||||
pub(crate) fn should_use_api_key(&self, preferred_auth_method: AuthMode) -> bool {
|
||||
pub(crate) fn should_use_api_key(
|
||||
&self,
|
||||
preferred_auth_method: AuthMode,
|
||||
is_openai_email: bool,
|
||||
) -> bool {
|
||||
if preferred_auth_method == AuthMode::ApiKey {
|
||||
return true;
|
||||
}
|
||||
// If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey.
|
||||
if is_openai_email {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.id_token
|
||||
.chatgpt_plan_type
|
||||
.as_ref()
|
||||
.is_none_or(|plan| plan.is_plan_that_should_use_api_key())
|
||||
}
|
||||
|
||||
pub fn is_openai_email(&self) -> bool {
|
||||
self.id_token
|
||||
.email
|
||||
.as_deref()
|
||||
.is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat subset of useful claims in id_token from auth.json.
|
||||
|
||||
@@ -17,7 +17,7 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
codex-arg0 = { path = "../arg0" }
|
||||
codex-common = { path = "../common" }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
|
||||
@@ -8,11 +8,15 @@ use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::git_info::git_diff_to_remote;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_login::AuthManager;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
|
||||
use mcp_types::JSONRPCErrorError;
|
||||
use mcp_types::RequestId;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -36,6 +40,7 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams;
|
||||
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
|
||||
use codex_protocol::mcp_protocol::ApplyPatchApprovalParams;
|
||||
use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse;
|
||||
use codex_protocol::mcp_protocol::AuthStatusChangeNotification;
|
||||
use codex_protocol::mcp_protocol::ClientRequest;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
|
||||
@@ -44,7 +49,6 @@ use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
|
||||
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
|
||||
use codex_protocol::mcp_protocol::InterruptConversationParams;
|
||||
use codex_protocol::mcp_protocol::InterruptConversationResponse;
|
||||
use codex_protocol::mcp_protocol::LOGIN_CHATGPT_COMPLETE_EVENT;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptResponse;
|
||||
use codex_protocol::mcp_protocol::NewConversationParams;
|
||||
@@ -55,6 +59,7 @@ use codex_protocol::mcp_protocol::SendUserMessageParams;
|
||||
use codex_protocol::mcp_protocol::SendUserMessageResponse;
|
||||
use codex_protocol::mcp_protocol::SendUserTurnParams;
|
||||
use codex_protocol::mcp_protocol::SendUserTurnResponse;
|
||||
use codex_protocol::mcp_protocol::ServerNotification;
|
||||
|
||||
// Duration before a ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
@@ -72,9 +77,11 @@ impl ActiveLogin {
|
||||
|
||||
/// Handles JSON-RPC messages for Codex conversations.
|
||||
pub(crate) struct CodexMessageProcessor {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
|
||||
active_login: Arc<Mutex<Option<ActiveLogin>>>,
|
||||
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
|
||||
@@ -83,14 +90,18 @@ pub(crate) struct CodexMessageProcessor {
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
conversation_manager,
|
||||
outgoing,
|
||||
codex_linux_sandbox_exe,
|
||||
config,
|
||||
conversation_listeners: HashMap::new(),
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -120,29 +131,26 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::RemoveConversationListener { request_id, params } => {
|
||||
self.remove_conversation_listener(request_id, params).await;
|
||||
}
|
||||
ClientRequest::GitDiffToRemote { request_id, params } => {
|
||||
self.git_diff_to_origin(request_id, params.cwd).await;
|
||||
}
|
||||
ClientRequest::LoginChatGpt { request_id } => {
|
||||
self.login_chatgpt(request_id).await;
|
||||
}
|
||||
ClientRequest::CancelLoginChatGpt { request_id, params } => {
|
||||
self.cancel_login_chatgpt(request_id, params.login_id).await;
|
||||
}
|
||||
ClientRequest::LogoutChatGpt { request_id } => {
|
||||
self.logout_chatgpt(request_id).await;
|
||||
}
|
||||
ClientRequest::GetAuthStatus { request_id, params } => {
|
||||
self.get_auth_status(request_id, params).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_chatgpt(&mut self, request_id: RequestId) {
|
||||
let config =
|
||||
match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("error loading config for login: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let config = self.config.as_ref();
|
||||
|
||||
let opts = LoginServerOptions {
|
||||
open_browser: false,
|
||||
@@ -179,6 +187,7 @@ impl CodexMessageProcessor {
|
||||
// Spawn background task to monitor completion.
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
LOGIN_CHATGPT_TIMEOUT,
|
||||
@@ -194,19 +203,30 @@ impl CodexMessageProcessor {
|
||||
(false, Some("Login timed out".to_string()))
|
||||
}
|
||||
};
|
||||
let notification = LoginChatGptCompleteNotification {
|
||||
let payload = LoginChatGptCompleteNotification {
|
||||
login_id,
|
||||
success,
|
||||
error: error_msg,
|
||||
};
|
||||
let params = serde_json::to_value(¬ification).ok();
|
||||
outgoing_clone
|
||||
.send_notification(OutgoingNotification {
|
||||
method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(),
|
||||
params,
|
||||
})
|
||||
.send_server_notification(ServerNotification::LoginChatGptComplete(payload))
|
||||
.await;
|
||||
|
||||
// Send an auth status change notification.
|
||||
if success {
|
||||
// Update in-memory auth cache now that login completed.
|
||||
auth_manager.reload();
|
||||
|
||||
// Notify clients with the actual current auth mode.
|
||||
let current_auth_method = auth_manager.auth().map(|a| a.mode);
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
outgoing_clone
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
|
||||
let mut guard = active_login.lock().await;
|
||||
if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
|
||||
@@ -255,6 +275,85 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout_chatgpt(&mut self, request_id: RequestId) {
|
||||
{
|
||||
// Cancel any active login attempt.
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
active.drop();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = self.auth_manager.logout() {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("logout failed: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
codex_protocol::mcp_protocol::LogoutChatGptResponse {},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send auth status change notification reflecting the current auth mode
|
||||
// after logout (which may fall back to API key via env var).
|
||||
let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode);
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn get_auth_status(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
params: codex_protocol::mcp_protocol::GetAuthStatusParams,
|
||||
) {
|
||||
let preferred_auth_method: AuthMode = self.auth_manager.preferred_auth_method();
|
||||
let include_token = params.include_token.unwrap_or(false);
|
||||
let do_refresh = params.refresh_token.unwrap_or(false);
|
||||
|
||||
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
|
||||
tracing::warn!("failed to refresh token while getting auth status: {err}");
|
||||
}
|
||||
|
||||
let response = match self.auth_manager.auth() {
|
||||
Some(auth) => {
|
||||
let (reported_auth_method, token_opt) = match auth.get_token().await {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
(Some(auth.mode), tok)
|
||||
}
|
||||
Ok(_) => (None, None),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get token for auth status: {err}");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
codex_protocol::mcp_protocol::GetAuthStatusResponse {
|
||||
auth_method: reported_auth_method,
|
||||
preferred_auth_method,
|
||||
auth_token: token_opt,
|
||||
}
|
||||
}
|
||||
None => codex_protocol::mcp_protocol::GetAuthStatusResponse {
|
||||
auth_method: None,
|
||||
preferred_auth_method,
|
||||
auth_token: None,
|
||||
},
|
||||
};
|
||||
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
|
||||
let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) {
|
||||
Ok(config) => config,
|
||||
@@ -514,6 +613,27 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) {
|
||||
let diff = git_diff_to_remote(&cwd).await;
|
||||
match diff {
|
||||
Some(value) => {
|
||||
let response = GitDiffToRemoteResponse {
|
||||
sha: value.sha,
|
||||
diff: value.diff,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
None => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("failed to compute git diff to remote for cwd: {cwd:?}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_bespoke_event_handling(
|
||||
@@ -616,6 +736,7 @@ fn derive_config_from_params(
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_subagent_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
};
|
||||
|
||||
@@ -161,6 +161,7 @@ impl CodexToolCallParam {
|
||||
base_instructions,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool: None,
|
||||
include_subagent_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
};
|
||||
|
||||
@@ -174,6 +174,11 @@ async fn run_codex_tool_session_inner(
|
||||
.await;
|
||||
|
||||
match event.msg {
|
||||
EventMsg::SubagentBegin(_)
|
||||
| EventMsg::SubagentForwarded(_)
|
||||
| EventMsg::SubagentEnd(_) => {
|
||||
// Ignore subagent orchestration for MCP echoing.
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
command,
|
||||
cwd,
|
||||
@@ -268,12 +273,14 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ConversationHistory(_)
|
||||
| EventMsg::ShutdownComplete => {
|
||||
// For now, we do not do anything extra for these
|
||||
// events. Note that
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Prototype MCP server.
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Result as IoResult;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
|
||||
use mcp_types::JSONRPCMessage;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -41,7 +46,10 @@ pub use crate::patch_approval::PatchApprovalResponse;
|
||||
/// plenty for an interactive CLI.
|
||||
const CHANNEL_CAPACITY: usize = 128;
|
||||
|
||||
pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()> {
|
||||
pub async fn run_main(
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
cli_config_overrides: CliConfigOverrides,
|
||||
) -> IoResult<()> {
|
||||
// Install a simple subscriber so `tracing` output is visible. Users can
|
||||
// control the log level with `RUST_LOG`.
|
||||
tracing_subscriber::fmt()
|
||||
@@ -77,10 +85,27 @@ pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()>
|
||||
}
|
||||
});
|
||||
|
||||
// Parse CLI overrides once and derive the base Config eagerly so later
|
||||
// components do not need to work with raw TOML values.
|
||||
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("error parsing -c overrides: {e}"),
|
||||
)
|
||||
})?;
|
||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
||||
})?;
|
||||
|
||||
// Task: process incoming messages.
|
||||
let processor_handle = tokio::spawn({
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
let mut processor = MessageProcessor::new(outgoing_message_sender, codex_linux_sandbox_exe);
|
||||
let mut processor = MessageProcessor::new(
|
||||
outgoing_message_sender,
|
||||
codex_linux_sandbox_exe,
|
||||
std::sync::Arc::new(config),
|
||||
);
|
||||
async move {
|
||||
while let Some(msg) = incoming_rx.recv().await {
|
||||
match msg {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_mcp_server::run_main;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
run_main(codex_linux_sandbox_exe).await?;
|
||||
run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex_message_processor::CodexMessageProcessor;
|
||||
use crate::codex_tool_config::CodexToolCallParam;
|
||||
@@ -12,8 +11,9 @@ use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_protocol::mcp_protocol::ClientRequest;
|
||||
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_login::AuthManager;
|
||||
use mcp_types::CallToolRequestParams;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ClientRequest as McpClientRequest;
|
||||
@@ -30,6 +30,7 @@ use mcp_types::ServerCapabilitiesTools;
|
||||
use mcp_types::ServerNotification;
|
||||
use mcp_types::TextContent;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task;
|
||||
use uuid::Uuid;
|
||||
@@ -49,13 +50,18 @@ impl MessageProcessor {
|
||||
pub(crate) fn new(
|
||||
outgoing: OutgoingMessageSender,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
let outgoing = Arc::new(outgoing);
|
||||
let conversation_manager = Arc::new(ConversationManager::default());
|
||||
let auth_manager =
|
||||
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
|
||||
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
|
||||
let codex_message_processor = CodexMessageProcessor::new(
|
||||
auth_manager,
|
||||
conversation_manager.clone(),
|
||||
outgoing.clone(),
|
||||
codex_linux_sandbox_exe.clone(),
|
||||
config,
|
||||
);
|
||||
Self {
|
||||
codex_message_processor,
|
||||
@@ -344,7 +350,7 @@ impl MessageProcessor {
|
||||
}
|
||||
}
|
||||
async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option<serde_json::Value>) {
|
||||
let (initial_prompt, config): (String, CodexConfig) = match arguments {
|
||||
let (initial_prompt, config): (String, Config) = match arguments {
|
||||
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
|
||||
Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) {
|
||||
Ok(cfg) => cfg,
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_core::protocol::Event;
|
||||
use codex_protocol::mcp_protocol::ServerNotification;
|
||||
use mcp_types::JSONRPC_VERSION;
|
||||
use mcp_types::JSONRPCError;
|
||||
use mcp_types::JSONRPCErrorError;
|
||||
@@ -121,6 +122,17 @@ impl OutgoingMessageSender {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
|
||||
let method = format!("codex/event/{}", notification);
|
||||
let params = match serde_json::to_value(¬ification) {
|
||||
Ok(serde_json::Value::Object(mut map)) => map.remove("data"),
|
||||
_ => None,
|
||||
};
|
||||
let outgoing_message =
|
||||
OutgoingMessage::Notification(OutgoingNotification { method, params });
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
|
||||
let outgoing_message = OutgoingMessage::Notification(notification);
|
||||
let _ = self.sender.send(outgoing_message).await;
|
||||
|
||||
142
codex-rs/mcp-server/tests/auth.rs
Normal file
142
codex-rs/mcp-server/tests/auth.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
|
||||
use mcp_test_support::McpProcess;
|
||||
use mcp_test_support::to_response;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::RequestId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
// Helper to create a config.toml; mirrors create_conversation.rs
|
||||
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "http://127.0.0.1:0/v1"
|
||||
wire_api = "chat"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_no_auth() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await
|
||||
.expect("send getAuthStatus");
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getAuthStatus timeout")
|
||||
.expect("getAuthStatus response");
|
||||
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, None, "expected no auth method");
|
||||
assert_eq!(status.auth_token, None, "expected no token");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await
|
||||
.expect("send getAuthStatus");
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getAuthStatus timeout")
|
||||
.expect("getAuthStatus response");
|
||||
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
|
||||
assert_eq!(status.auth_token, Some("sk-test-key".to_string()));
|
||||
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key_no_include_token() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
// Build params via struct so None field is omitted in wire JSON.
|
||||
let params = GetAuthStatusParams {
|
||||
include_token: None,
|
||||
refresh_token: Some(false),
|
||||
};
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(params)
|
||||
.await
|
||||
.expect("send getAuthStatus");
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getAuthStatus timeout")
|
||||
.expect("getAuthStatus response");
|
||||
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
|
||||
assert!(status.auth_token.is_none(), "token must be omitted");
|
||||
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
|
||||
}
|
||||
@@ -86,9 +86,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
// This is the first request from the server, so the id should be 0 given
|
||||
// how things are currently implemented.
|
||||
let elicitation_request_id = RequestId::Integer(0);
|
||||
let elicitation_request_id = elicitation_request.id.clone();
|
||||
let params = serde_json::from_value::<ExecApprovalElicitRequestParams>(
|
||||
elicitation_request
|
||||
.params
|
||||
|
||||
@@ -13,6 +13,8 @@ use anyhow::Context;
|
||||
use assert_cmd::prelude::*;
|
||||
use codex_mcp_server::CodexToolCallParam;
|
||||
use codex_protocol::mcp_protocol::AddConversationListenerParams;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
use codex_protocol::mcp_protocol::InterruptConversationParams;
|
||||
use codex_protocol::mcp_protocol::NewConversationParams;
|
||||
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
|
||||
@@ -217,6 +219,34 @@ impl McpProcess {
|
||||
self.send_request("interruptConversation", params).await
|
||||
}
|
||||
|
||||
/// Send a `getAuthStatus` JSON-RPC request.
|
||||
pub async fn send_get_auth_status_request(
|
||||
&mut self,
|
||||
params: GetAuthStatusParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("getAuthStatus", params).await
|
||||
}
|
||||
|
||||
/// Send a `loginChatGpt` JSON-RPC request.
|
||||
pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("loginChatGpt", None).await
|
||||
}
|
||||
|
||||
/// Send a `cancelLoginChatGpt` JSON-RPC request.
|
||||
pub async fn send_cancel_login_chat_gpt_request(
|
||||
&mut self,
|
||||
params: CancelLoginChatGptParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("cancelLoginChatGpt", params).await
|
||||
}
|
||||
|
||||
/// Send a `logoutChatGpt` JSON-RPC request.
|
||||
pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("logoutChatGpt", None).await
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&mut self,
|
||||
method: &str,
|
||||
|
||||
146
codex-rs/mcp-server/tests/login.rs
Normal file
146
codex-rs/mcp-server/tests/login.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptResponse;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptResponse;
|
||||
use codex_protocol::mcp_protocol::LogoutChatGptResponse;
|
||||
use mcp_test_support::McpProcess;
|
||||
use mcp_test_support::to_response;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::RequestId;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
// Helper to create a config.toml; mirrors create_conversation.rs
|
||||
fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "http://127.0.0.1:0/v1"
|
||||
wire_api = "chat"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn logout_chatgpt_removes_auth() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
|
||||
assert!(codex_home.path().join("auth.json").exists());
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
let id = mcp
|
||||
.send_logout_chat_gpt_request()
|
||||
.await
|
||||
.expect("send logoutChatGpt");
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(id)),
|
||||
)
|
||||
.await
|
||||
.expect("logoutChatGpt timeout")
|
||||
.expect("logoutChatGpt response");
|
||||
let _ok: LogoutChatGptResponse = to_response(resp).expect("deserialize logout response");
|
||||
|
||||
assert!(
|
||||
!codex_home.path().join("auth.json").exists(),
|
||||
"auth.json should be deleted"
|
||||
);
|
||||
|
||||
// Verify status reflects signed-out state.
|
||||
let status_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(false),
|
||||
})
|
||||
.await
|
||||
.expect("send getAuthStatus");
|
||||
let status_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getAuthStatus timeout")
|
||||
.expect("getAuthStatus response");
|
||||
let status: GetAuthStatusResponse = to_response(status_resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, None);
|
||||
assert_eq!(status.auth_token, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn login_and_cancel_chatgpt() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
let login_id = mcp
|
||||
.send_login_chat_gpt_request()
|
||||
.await
|
||||
.expect("send loginChatGpt");
|
||||
let login_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
|
||||
)
|
||||
.await
|
||||
.expect("loginChatGpt timeout")
|
||||
.expect("loginChatGpt response");
|
||||
let login: LoginChatGptResponse = to_response(login_resp).expect("deserialize login resp");
|
||||
|
||||
let cancel_id = mcp
|
||||
.send_cancel_login_chat_gpt_request(CancelLoginChatGptParams {
|
||||
login_id: login.login_id,
|
||||
})
|
||||
.await
|
||||
.expect("send cancelLoginChatGpt");
|
||||
let cancel_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),
|
||||
)
|
||||
.await
|
||||
.expect("cancelLoginChatGpt timeout")
|
||||
.expect("cancelLoginChatGpt response");
|
||||
let _ok: CancelLoginChatGptResponse =
|
||||
to_response(cancel_resp).expect("deserialize cancel response");
|
||||
|
||||
// Optionally observe the completion notification; do not fail if it races.
|
||||
let maybe_note = timeout(
|
||||
Duration::from_secs(2),
|
||||
mcp.read_stream_until_notification_message("codex/event/login_chat_gpt_complete"),
|
||||
)
|
||||
.await;
|
||||
if maybe_note.is_err() {
|
||||
eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel");
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,21 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
|
||||
|
||||
// Prepend header to each generated .ts file
|
||||
let ts_files = ts_files_in(out_dir)?;
|
||||
|
||||
@@ -11,12 +11,15 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
tracing = "0.1.41"
|
||||
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod config_types;
|
||||
pub mod mcp_protocol;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod parse_command;
|
||||
pub mod plan_tool;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::protocol::TurnAbortReason;
|
||||
use mcp_types::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use strum_macros::Display;
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -26,6 +27,23 @@ impl Display for ConversationId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
|
||||
#[ts(type = "string")]
|
||||
pub struct GitSha(pub String);
|
||||
|
||||
impl GitSha {
|
||||
pub fn new(sha: &str) -> Self {
|
||||
Self(sha.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMode {
|
||||
ApiKey,
|
||||
ChatGPT,
|
||||
}
|
||||
|
||||
/// Request from the client to the server.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(tag = "method", rename_all = "camelCase")]
|
||||
@@ -60,6 +78,11 @@ pub enum ClientRequest {
|
||||
request_id: RequestId,
|
||||
params: RemoveConversationListenerParams,
|
||||
},
|
||||
GitDiffToRemote {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: GitDiffToRemoteParams,
|
||||
},
|
||||
LoginChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
@@ -69,6 +92,15 @@ pub enum ClientRequest {
|
||||
request_id: RequestId,
|
||||
params: CancelLoginChatGptParams,
|
||||
},
|
||||
LogoutChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
},
|
||||
GetAuthStatus {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: GetAuthStatusParams,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
|
||||
@@ -139,16 +171,11 @@ pub struct LoginChatGptResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
// Event name for notifying client of login completion or failure.
|
||||
pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginChatGptCompleteNotification {
|
||||
pub login_id: Uuid,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
pub struct GitDiffToRemoteResponse {
|
||||
pub sha: GitSha,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
@@ -157,10 +184,45 @@ pub struct CancelLoginChatGptParams {
|
||||
pub login_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitDiffToRemoteParams {
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelLoginChatGptResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoutChatGptParams {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoutChatGptResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetAuthStatusParams {
|
||||
/// If true, include the current auth token (if available) in the response.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_token: Option<bool>,
|
||||
/// If true, attempt to refresh the token before returning status.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetAuthStatusResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<AuthMode>,
|
||||
pub preferred_auth_method: AuthMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendUserMessageParams {
|
||||
@@ -293,6 +355,34 @@ pub struct ApplyPatchApprovalResponse {
|
||||
pub decision: ReviewDecision,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginChatGptCompleteNotification {
|
||||
pub login_id: Uuid,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthStatusChangeNotification {
|
||||
/// Current authentication method; omitted if signed out.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<AuthMode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum ServerNotification {
|
||||
/// Authentication status changed
|
||||
AuthStatusChange(AuthStatusChangeNotification),
|
||||
|
||||
/// ChatGPT login flow completed
|
||||
LoginChatGptComplete(LoginChatGptCompleteNotification),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -24,6 +24,10 @@ pub enum ResponseInputItem {
|
||||
call_id: String,
|
||||
result: Result<CallToolResult, String>,
|
||||
},
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -77,6 +81,20 @@ pub enum ResponseItem {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
CustomToolCall {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
|
||||
call_id: String,
|
||||
name: String,
|
||||
input: String,
|
||||
},
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
output: String,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
@@ -114,6 +132,9 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
),
|
||||
},
|
||||
},
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
Self::CustomToolCallOutput { call_id, output }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +204,6 @@ impl From<Vec<InputItem>> for ResponseInputItem {
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<ContentItem>>(),
|
||||
}
|
||||
@@ -197,10 +217,8 @@ pub struct ShellToolCallParams {
|
||||
pub command: Vec<String>,
|
||||
pub workdir: Option<String>,
|
||||
|
||||
/// This is the maximum time in seconds that the command is allowed to run.
|
||||
#[serde(rename = "timeout")]
|
||||
// The wire format uses `timeout`, which has ambiguous units, so we use
|
||||
// `timeout_ms` as the field name so it is clear in code.
|
||||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||||
#[serde(alias = "timeout")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub with_escalated_permissions: Option<bool>,
|
||||
@@ -2,6 +2,7 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParsedCommand {
|
||||
Read {
|
||||
cmd: String,
|
||||
|
||||
@@ -22,6 +22,7 @@ use uuid::Uuid;
|
||||
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::message_history::HistoryEntry;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::parse_command::ParsedCommand;
|
||||
use crate::plan_tool::UpdatePlanArgs;
|
||||
|
||||
@@ -137,6 +138,10 @@ pub enum Op {
|
||||
/// Request a single history entry identified by `log_id` + `offset`.
|
||||
GetHistoryEntryRequest { offset: usize, log_id: u64 },
|
||||
|
||||
/// Request the full in-memory conversation transcript for the current session.
|
||||
/// Reply is delivered via `EventMsg::ConversationHistory`.
|
||||
GetHistory,
|
||||
|
||||
/// Request the list of MCP tools available across all configured servers.
|
||||
/// Reply is delivered via `EventMsg::McpListToolsResponse`.
|
||||
ListMcpTools,
|
||||
@@ -446,6 +451,10 @@ pub enum EventMsg {
|
||||
|
||||
BackgroundEvent(BackgroundEventEvent),
|
||||
|
||||
/// Notification that a model stream experienced an error or disconnect
|
||||
/// and the system is handling it (e.g., retrying with backoff).
|
||||
StreamError(StreamErrorEvent),
|
||||
|
||||
/// Notification that the agent is about to apply a code patch. Mirrors
|
||||
/// `ExecCommandBegin` so front‑ends can show progress indicators.
|
||||
PatchApplyBegin(PatchApplyBeginEvent),
|
||||
@@ -467,6 +476,16 @@ pub enum EventMsg {
|
||||
|
||||
/// Notification that the agent is shutting down.
|
||||
ShutdownComplete,
|
||||
|
||||
ConversationHistory(ConversationHistoryResponseEvent),
|
||||
|
||||
// --- Subagent orchestration events ---
|
||||
/// Emitted when a subagent starts.
|
||||
SubagentBegin(SubagentBeginEvent),
|
||||
/// Forwards a nested event produced by a running subagent.
|
||||
SubagentForwarded(SubagentForwardedEvent),
|
||||
/// Emitted when a subagent finishes.
|
||||
SubagentEnd(SubagentEndEvent),
|
||||
}
|
||||
|
||||
// Individual event payload types matching each `EventMsg` variant.
|
||||
@@ -490,6 +509,28 @@ pub struct TokenUsage {
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SubagentBeginEvent {
|
||||
pub subagent_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SubagentEndEvent {
|
||||
pub subagent_id: String,
|
||||
pub name: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_agent_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SubagentForwardedEvent {
|
||||
pub subagent_id: String,
|
||||
pub name: String,
|
||||
pub event: Box<EventMsg>,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.total_tokens == 0
|
||||
@@ -647,6 +688,14 @@ impl McpToolCallEndEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Response payload for `Op::GetHistory` containing the current session's
|
||||
/// in-memory transcript.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ConversationHistoryResponseEvent {
|
||||
pub conversation_id: Uuid,
|
||||
pub entries: Vec<ResponseItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ExecCommandBeginEvent {
|
||||
/// Identifier so this can be paired with the ExecCommandEnd event.
|
||||
@@ -666,10 +715,15 @@ pub struct ExecCommandEndEvent {
|
||||
pub stdout: String,
|
||||
/// Captured stderr
|
||||
pub stderr: String,
|
||||
/// Captured aggregated output
|
||||
#[serde(default)]
|
||||
pub aggregated_output: String,
|
||||
/// The command's exit code.
|
||||
pub exit_code: i32,
|
||||
/// The duration of the command execution.
|
||||
pub duration: Duration,
|
||||
/// Formatted output from the command, as seen by the model.
|
||||
pub formatted_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -721,6 +775,11 @@ pub struct BackgroundEventEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct StreamErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PatchApplyBeginEvent {
|
||||
/// Identifier so this can be paired with the PatchApplyEnd event.
|
||||
|
||||
@@ -22,6 +22,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
arboard = "3"
|
||||
async-stream = "0.3.6"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -33,18 +35,22 @@ codex-common = { path = "../common", features = [
|
||||
"sandbox_summary",
|
||||
] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
codex-file-search = { path = "../file-search" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-ollama = { path = "../ollama" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
|
||||
diffy = "0.4.2"
|
||||
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
|
||||
image = { version = "^0.25.6", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
] }
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
once_cell = "1"
|
||||
path-clean = "1.0.1"
|
||||
rand = "0.9"
|
||||
ratatui = { version = "0.29.0", features = [
|
||||
"scrolling-regions",
|
||||
"unstable-rendered-line-info",
|
||||
@@ -59,6 +65,7 @@ shlex = "1.3.0"
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
supports-color = "3.0.2"
|
||||
tempfile = "3"
|
||||
textwrap = "0.16.2"
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
@@ -67,6 +74,7 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tokio-stream = "0.1.17"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
@@ -75,7 +83,6 @@ tui-markdown = "0.3.3"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
rand = "0.9"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,714 +1,318 @@
|
||||
use crate::LoginStatus;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::get_login_status;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreen;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::transcript_app::TranscriptApp;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_login::AuthManager;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::terminal::supports_keyboard_enhancement;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
/// Time window for debouncing redraw requests.
|
||||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
|
||||
|
||||
/// Top-level application state: which full-screen view is currently active.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum AppState<'a> {
|
||||
Onboarding {
|
||||
screen: OnboardingScreen,
|
||||
},
|
||||
/// The main chat UI is visible.
|
||||
Chat {
|
||||
/// Boxed to avoid a large enum variant and reduce the overall size of
|
||||
/// `AppState`.
|
||||
widget: Box<ChatWidget<'a>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct App<'a> {
|
||||
pub(crate) struct App {
|
||||
server: Arc<ConversationManager>,
|
||||
app_event_tx: AppEventSender,
|
||||
app_event_rx: Receiver<AppEvent>,
|
||||
app_state: AppState<'a>,
|
||||
chat_widget: ChatWidget,
|
||||
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
config: Config,
|
||||
|
||||
file_search: FileSearchManager,
|
||||
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
transcript_lines: Vec<Line<'static>>,
|
||||
|
||||
// Transcript overlay state
|
||||
transcript_overlay: Option<TranscriptApp>,
|
||||
deferred_history_lines: Vec<Line<'static>>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
commit_anim_running: Arc<AtomicBool>,
|
||||
|
||||
/// Channel to schedule one-shot animation frames; coalesced by a single
|
||||
/// scheduler thread.
|
||||
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
|
||||
}
|
||||
|
||||
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
/// deferred until after the Git warning screen is dismissed.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatWidgetArgs {
|
||||
pub(crate) config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
enhanced_keys_supported: bool,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
impl App {
|
||||
pub async fn run(
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
show_trust_screen: bool,
|
||||
) -> Self {
|
||||
let conversation_manager = Arc::new(ConversationManager::default());
|
||||
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
initial_images: Vec<PathBuf>,
|
||||
) -> Result<TokenUsage> {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
|
||||
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
|
||||
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
// re-publishing the events as AppEvents, as appropriate.
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
// This timeout is necessary to avoid holding the event lock
|
||||
// that crossterm::event::read() acquires. In particular,
|
||||
// reading the cursor position (crossterm::cursor::position())
|
||||
// needs to acquire the event lock, and so will fail if it
|
||||
// can't acquire it within 2 sec. Resizing the terminal
|
||||
// crashes the app if the cursor position can't be read.
|
||||
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||||
if let Ok(event) = crossterm::event::read() {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
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. 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");
|
||||
app_event_tx.send(AppEvent::Paste(pasted));
|
||||
}
|
||||
_ => {
|
||||
// Ignore any other events.
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timeout expired, no `Event` is available
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let login_status = get_login_status(&config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &config, show_trust_screen);
|
||||
let app_state = if should_show_onboarding {
|
||||
let show_login_screen = should_show_login_screen(login_status, &config);
|
||||
let chat_widget_args = ChatWidgetArgs {
|
||||
config: config.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
};
|
||||
AppState::Onboarding {
|
||||
screen: OnboardingScreen::new(OnboardingScreenArgs {
|
||||
event_tx: app_event_tx.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
show_trust_screen,
|
||||
show_login_screen,
|
||||
chat_widget_args,
|
||||
login_status,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
let chat_widget = ChatWidget::new(
|
||||
config.clone(),
|
||||
conversation_manager.clone(),
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
);
|
||||
AppState::Chat {
|
||||
widget: Box::new(chat_widget),
|
||||
}
|
||||
};
|
||||
let chat_widget = ChatWidget::new(
|
||||
config.clone(),
|
||||
conversation_manager.clone(),
|
||||
tui.frame_requester(),
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
);
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
|
||||
// Spawn a single scheduler thread that coalesces both debounced redraw
|
||||
// requests and animation frame requests, and emits a single Redraw event
|
||||
// at the earliest requested time.
|
||||
let (frame_tx, frame_rx) = channel::<Instant>();
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
let mut next_deadline: Option<Instant> = None;
|
||||
loop {
|
||||
if next_deadline.is_none() {
|
||||
match frame_rx.recv() {
|
||||
Ok(deadline) => next_deadline = Some(deadline),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let deadline = next_deadline.expect("deadline set");
|
||||
let now = Instant::now();
|
||||
let timeout = if deadline > now {
|
||||
deadline - now
|
||||
} else {
|
||||
Duration::from_millis(0)
|
||||
};
|
||||
|
||||
match frame_rx.recv_timeout(timeout) {
|
||||
Ok(new_deadline) => {
|
||||
next_deadline =
|
||||
Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline)));
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
app_event_tx.send(AppEvent::Redraw);
|
||||
next_deadline = None;
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Self {
|
||||
let mut app = Self {
|
||||
server: conversation_manager,
|
||||
app_event_tx,
|
||||
pending_history_lines: Vec::new(),
|
||||
app_event_rx,
|
||||
app_state,
|
||||
chat_widget,
|
||||
config,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
transcript_lines: Vec::new(),
|
||||
transcript_overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
frame_schedule_tx: frame_tx,
|
||||
}
|
||||
};
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
||||
while select! {
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
app.handle_event(tui, event)?
|
||||
}
|
||||
Some(event) = tui_events.next() => {
|
||||
app.handle_tui_event(tui, event).await?
|
||||
}
|
||||
} {}
|
||||
tui.terminal.clear()?;
|
||||
Ok(app.token_usage())
|
||||
}
|
||||
|
||||
fn schedule_frame_in(&self, dur: Duration) {
|
||||
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
|
||||
}
|
||||
|
||||
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
// Schedule the first render immediately.
|
||||
let _ = self.frame_schedule_tx.send(Instant::now());
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
pub(crate) async fn handle_tui_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done {
|
||||
// Exit alternate screen and restore viewport.
|
||||
let _ = tui.leave_alt_screen();
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
self.transcript_overlay = None;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
} else {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
TuiEvent::Key(key_event) => {
|
||||
self.handle_key_event(tui, key_event).await;
|
||||
}
|
||||
AppEvent::RequestRedraw => {
|
||||
self.schedule_frame_in(REDRAW_DEBOUNCE);
|
||||
TuiEvent::Paste(pasted) => {
|
||||
// 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");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
AppEvent::ScheduleFrameIn(dur) => {
|
||||
self.schedule_frame_in(dur);
|
||||
}
|
||||
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 {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.on_ctrl_c();
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
frame.render_widget_ref(&self.chat_widget, frame.area());
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
},
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('z'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.suspend(terminal)?;
|
||||
}
|
||||
// No-op on non-Unix platforms.
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if widget.composer_is_empty() {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
} else {
|
||||
// Treat Ctrl+D as a normal key event when the composer
|
||||
// is not empty so that it doesn't quit the application
|
||||
// prematurely.
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
}
|
||||
};
|
||||
)?;
|
||||
}
|
||||
AppEvent::Paste(text) => {
|
||||
self.dispatch_paste_event(text);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
break;
|
||||
}
|
||||
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
AppEvent::DiffResult(text) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_diff_output(text);
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::New => {
|
||||
// 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(),
|
||||
self.enhanced_keys_supported,
|
||||
));
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
widget.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.open_model_popup();
|
||||
}
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_login::logout(&self.config.codex_home) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_diff_in_progress();
|
||||
}
|
||||
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let text = match get_git_diff().await {
|
||||
Ok((is_git_repo, diff_text)) => {
|
||||
if is_git_repo {
|
||||
diff_text
|
||||
} else {
|
||||
"`/diff` — _not inside a git repository_".to_string()
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Failed to compute diff: {e}"),
|
||||
};
|
||||
tx.send(AppEvent::DiffResult(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();
|
||||
}
|
||||
}
|
||||
SlashCommand::Mcp => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_mcp_output();
|
||||
}
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
id: "1".to_string(),
|
||||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
// call_id: "1".to_string(),
|
||||
// command: vec!["git".into(), "apply".into()],
|
||||
// cwd: self.config.cwd.clone(),
|
||||
// reason: Some("test".to_string()),
|
||||
// }),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "1".to_string(),
|
||||
changes: HashMap::from([
|
||||
(
|
||||
PathBuf::from("/tmp/test.txt"),
|
||||
FileChange::Add {
|
||||
content: "test".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("/tmp/test2.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: "+test\n-test2".to_string(),
|
||||
move_path: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
reason: None,
|
||||
grant_root: Some(PathBuf::from("/tmp")),
|
||||
},
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
AppEvent::OnboardingAuthComplete(result) => {
|
||||
if let AppState::Onboarding { screen } = &mut self.app_state {
|
||||
screen.on_auth_complete(result);
|
||||
}
|
||||
}
|
||||
AppEvent::OnboardingComplete(ChatWidgetArgs {
|
||||
config,
|
||||
enhanced_keys_supported,
|
||||
initial_images,
|
||||
initial_prompt,
|
||||
}) => {
|
||||
self.app_state = AppState::Chat {
|
||||
widget: Box::new(ChatWidget::new(
|
||||
config,
|
||||
self.server.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
)),
|
||||
}
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.set_model(model);
|
||||
}
|
||||
TuiEvent::AttachImage {
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.attach_image(path, width, height, format_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[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(())
|
||||
fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
self.chat_widget = ChatWidget::new(
|
||||
self.config.clone(),
|
||||
self.server.clone(),
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.insert_lines(lines.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_lines.extend(lines.clone());
|
||||
if self.transcript_overlay.is_some() {
|
||||
self.deferred_history_lines.extend(lines);
|
||||
} else {
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.insert_lines(cell.transcript_lines());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_lines.extend(cell.transcript_lines());
|
||||
let display = cell.display_lines();
|
||||
if !display.is_empty() {
|
||||
if self.transcript_overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
|
||||
AppEvent::DiffResult(text) => {
|
||||
// Clear the in-progress state in the bottom pane
|
||||
self.chat_widget.on_diff_complete();
|
||||
// Enter alternate screen using TUI helper and build pager lines
|
||||
let _ = tui.enter_alt_screen();
|
||||
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
|
||||
vec!["No changes detected.".italic().into()]
|
||||
} else {
|
||||
text.lines().map(ansi_escape_line).collect()
|
||||
};
|
||||
self.transcript_overlay = Some(TranscriptApp::with_title(
|
||||
pager_lines,
|
||||
"D I F F".to_string(),
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
self.chat_widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(model);
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||
match &self.app_state {
|
||||
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
|
||||
}
|
||||
self.chat_widget.token_usage().clone()
|
||||
}
|
||||
|
||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
if matches!(self.app_state, AppState::Onboarding { .. }) {
|
||||
terminal.clear()?;
|
||||
}
|
||||
|
||||
let screen_size = terminal.size()?;
|
||||
let last_known_screen_size = terminal.last_known_screen_size;
|
||||
if screen_size != last_known_screen_size {
|
||||
let cursor_pos = terminal.get_cursor_position()?;
|
||||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||
if cursor_pos.y != last_known_cursor_pos.y {
|
||||
// The terminal was resized. The only point of reference we have for where our viewport
|
||||
// was moved is the cursor position.
|
||||
// NB this assumes that the cursor was not wrapped as part of the resize.
|
||||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||
|
||||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||
x: 0,
|
||||
y: cursor_delta,
|
||||
});
|
||||
terminal.set_viewport_area(new_viewport_area);
|
||||
terminal.clear()?;
|
||||
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.chat_widget.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.chat_widget.composer_is_empty() => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
}
|
||||
}
|
||||
|
||||
let size = terminal.size()?;
|
||||
let desired_height = match &self.app_state {
|
||||
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||
AppState::Onboarding { .. } => size.height,
|
||||
};
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
area.height = desired_height.min(size.height);
|
||||
area.width = size.width;
|
||||
if area.bottom() > size.height {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||||
area.y = size.height - area.height;
|
||||
}
|
||||
if area != terminal.viewport_area {
|
||||
terminal.clear()?;
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
if !self.pending_history_lines.is_empty() {
|
||||
crate::insert_history::insert_history_lines(
|
||||
terminal,
|
||||
self.pending_history_lines.clone(),
|
||||
);
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
terminal.draw(|frame| match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
frame.render_widget_ref(&**widget, frame.area())
|
||||
}
|
||||
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a KeyEvent to the current view and let it decide what to do
|
||||
/// with it.
|
||||
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
AppState::Onboarding { screen } => match key_event.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
_ => screen.handle_key_event(key_event),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
AppState::Onboarding { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
AppState::Onboarding { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_show_onboarding(
|
||||
login_status: LoginStatus,
|
||||
config: &Config,
|
||||
show_trust_screen: bool,
|
||||
) -> bool {
|
||||
if show_trust_screen {
|
||||
return true;
|
||||
}
|
||||
|
||||
should_show_login_screen(login_status, config)
|
||||
}
|
||||
|
||||
fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool {
|
||||
match login_status {
|
||||
LoginStatus::NotAuthenticated => true,
|
||||
LoginStatus::AuthMode(method) => method != config.preferred_auth_method,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_login::AuthMode;
|
||||
|
||||
fn make_config(preferred: AuthMode) -> Config {
|
||||
let mut cfg = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("load default config");
|
||||
cfg.preferred_auth_method = preferred;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_not_authenticated() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::NotAuthenticated,
|
||||
&cfg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_api_key_but_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_api_key_and_prefers_api_key() {
|
||||
let cfg = make_config(AuthMode::ApiKey);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_chatgpt_and_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ChatGPT),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -13,20 +13,8 @@ use codex_core::protocol_config_types::ReasoningEffort;
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
/// Request a redraw which will be debounced by the [`App`].
|
||||
RequestRedraw,
|
||||
|
||||
/// Actually draw the next frame.
|
||||
Redraw,
|
||||
|
||||
/// Schedule a one-shot animation frame roughly after the given duration.
|
||||
/// Multiple requests are coalesced by the central frame scheduler.
|
||||
ScheduleFrameIn(Duration),
|
||||
|
||||
KeyEvent(KeyEvent),
|
||||
|
||||
/// Text pasted from the terminal clipboard.
|
||||
Paste(String),
|
||||
/// Start a new session.
|
||||
NewSession,
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
@@ -35,10 +23,6 @@ pub(crate) enum AppEvent {
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(codex_core::protocol::Op),
|
||||
|
||||
/// Dispatch a recognized slash command from the UI (composer) to the app
|
||||
/// layer so it can be handled centrally.
|
||||
DispatchCommand(SlashCommand),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
@@ -55,19 +39,22 @@ pub(crate) enum AppEvent {
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
InsertHistoryLines(Vec<Line<'static>>),
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
StartCommitAnimation,
|
||||
StopCommitAnimation,
|
||||
CommitTick,
|
||||
|
||||
/// Onboarding: result of login_with_chatgpt.
|
||||
OnboardingAuthComplete(Result<(), String>),
|
||||
OnboardingComplete(ChatWidgetArgs),
|
||||
|
||||
/// Update the current reasoning effort in the running app and widget.
|
||||
UpdateReasoningEffort(ReasoningEffort),
|
||||
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
UpdateAskForApprovalPolicy(AskForApproval),
|
||||
|
||||
/// Update the current sandbox policy in the running app and widget.
|
||||
UpdateSandboxPolicy(SandboxPolicy),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::session_log;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppEventSender {
|
||||
pub app_event_tx: Sender<AppEvent>,
|
||||
pub app_event_tx: UnboundedSender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventSender {
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
|
||||
Self { app_event_tx }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ use super::BottomPaneView;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Modal overlay asking the user to approve/deny a sequence of requests.
|
||||
pub(crate) struct ApprovalModalView<'a> {
|
||||
current: UserApprovalWidget<'a>,
|
||||
pub(crate) struct ApprovalModalView {
|
||||
current: UserApprovalWidget,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ApprovalModalView<'_> {
|
||||
impl ApprovalModalView {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
current: UserApprovalWidget::new(request, app_event_tx.clone()),
|
||||
@@ -41,13 +41,13 @@ impl ApprovalModalView<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
impl BottomPaneView for ApprovalModalView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
self.current.on_ctrl_c();
|
||||
self.queue.clear();
|
||||
CancellationEvent::Handled
|
||||
@@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -87,15 +87,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let first = make_exec_request();
|
||||
let mut view = ApprovalModalView::new(first, tx);
|
||||
view.enqueue_request(make_exec_request());
|
||||
|
||||
let (tx_raw2, _rx2) = channel::<AppEvent>();
|
||||
let (tx2, _rx2) = unbounded_channel::<AppEvent>();
|
||||
let mut pane = BottomPane::new(super::super::BottomPaneParams {
|
||||
app_event_tx: AppEventSender::new(tx_raw2),
|
||||
app_event_tx: AppEventSender::new(tx2),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
|
||||
@@ -7,10 +7,10 @@ use super::BottomPane;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||
pub(crate) trait BottomPaneView<'a> {
|
||||
pub(crate) trait BottomPaneView {
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {}
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {}
|
||||
|
||||
/// Return `true` if the view has finished and should be removed.
|
||||
fn is_complete(&self) -> bool {
|
||||
@@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C while this view is active.
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
CancellationEvent::Ignored
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
/// Render the view: this will be displayed in place of the composer.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
/// Update the status indicator animated header. Default no-op.
|
||||
fn update_status_header(&mut self, _header: String) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/// Called when task completes to check if the view should be hidden.
|
||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||
false
|
||||
|
||||
@@ -23,6 +23,7 @@ use ratatui::widgets::WidgetRef;
|
||||
use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -30,6 +31,16 @@ use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use codex_file_search::FileMatch;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
// Heuristic thresholds for detecting paste-like input bursts.
|
||||
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
||||
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
||||
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
||||
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
@@ -38,9 +49,16 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
/// Result returned when the user interacts with the text area.
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Command(SlashCommand),
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct AttachedImage {
|
||||
placeholder: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
struct TokenUsageInfo {
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
@@ -69,7 +87,15 @@ pub(crate) struct ChatComposer {
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
token_usage_info: Option<TokenUsageInfo>,
|
||||
has_focus: bool,
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
// Heuristic state to detect non-bracketed paste bursts.
|
||||
last_plain_char_time: Option<Instant>,
|
||||
consecutive_plain_char_burst: u16,
|
||||
paste_burst_until: Option<Instant>,
|
||||
// Buffer to accumulate characters during a detected non-bracketed paste burst.
|
||||
paste_burst_buffer: String,
|
||||
in_paste_burst_mode: bool,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -101,7 +127,13 @@ impl ChatComposer {
|
||||
pending_pastes: Vec::new(),
|
||||
token_usage_info: None,
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
last_plain_char_time: None,
|
||||
consecutive_plain_char_burst: 0,
|
||||
paste_burst_until: None,
|
||||
paste_burst_buffer: String::new(),
|
||||
in_paste_burst_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,11 +221,29 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.textarea.insert_str(&pasted);
|
||||
}
|
||||
// Explicit paste events should not trigger Enter suppression.
|
||||
self.last_plain_char_time = None;
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.paste_burst_until = None;
|
||||
self.sync_command_popup();
|
||||
self.sync_file_search_popup();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) {
|
||||
let placeholder = format!("[image {width}x{height} {format_label}]");
|
||||
// Insert as an element to match large paste placeholder behavior:
|
||||
// styled distinctly and treated atomically for cursor/mutations.
|
||||
self.textarea.insert_element(&placeholder);
|
||||
self.attached_images
|
||||
.push(AttachedImage { placeholder, path });
|
||||
}
|
||||
|
||||
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
let images = std::mem::take(&mut self.attached_images);
|
||||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -275,6 +325,11 @@ impl ChatComposer {
|
||||
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
}
|
||||
// After completing the command, move cursor to the end.
|
||||
if !self.textarea.text().is_empty() {
|
||||
let end = self.textarea.text().len();
|
||||
self.textarea.set_cursor(end);
|
||||
}
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
@@ -284,15 +339,15 @@ impl ChatComposer {
|
||||
..
|
||||
} => {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
// Send command to the app layer.
|
||||
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
|
||||
|
||||
// Clear textarea so no residual text remains.
|
||||
self.textarea.set_text("");
|
||||
|
||||
let result = (InputResult::Command(*cmd), true);
|
||||
|
||||
// Hide popup since the command has been dispatched.
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
|
||||
return result;
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
self.handle_key_event_without_popup(key_event)
|
||||
@@ -339,19 +394,74 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_match() {
|
||||
let sel_path = sel.to_string();
|
||||
// Drop popup borrow before using self mutably again.
|
||||
self.insert_selected_path(&sel_path);
|
||||
let Some(sel) = popup.selected_match() else {
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
};
|
||||
|
||||
let sel_path = sel.to_string();
|
||||
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
|
||||
let is_image = Self::is_image_path(&sel_path);
|
||||
if is_image {
|
||||
// Determine dimensions; if that fails fall back to normal path insertion.
|
||||
let path_buf = PathBuf::from(&sel_path);
|
||||
if let Ok((w, h)) = image::image_dimensions(&path_buf) {
|
||||
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
|
||||
// using the flat text and byte-offset cursor API.
|
||||
let cursor_offset = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
let before_cursor = &text[..cursor_offset];
|
||||
let after_cursor = &text[cursor_offset..];
|
||||
|
||||
// Determine token boundaries in the full text.
|
||||
let start_idx = before_cursor
|
||||
.char_indices()
|
||||
.rfind(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, c)| idx + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
let end_rel_idx = after_cursor
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(idx, _)| idx)
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = cursor_offset + end_rel_idx;
|
||||
|
||||
self.textarea.replace_range(start_idx..end_idx, "");
|
||||
self.textarea.set_cursor(start_idx);
|
||||
|
||||
let format_label = match Path::new(&sel_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
{
|
||||
Some(ext) if ext == "png" => "PNG",
|
||||
Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG",
|
||||
_ => "IMG",
|
||||
};
|
||||
self.attach_image(path_buf.clone(), w, h, format_label);
|
||||
// Add a trailing space to keep typing fluid.
|
||||
self.textarea.insert_str(" ");
|
||||
} else {
|
||||
// Fallback to plain path insertion if metadata read fails.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
} else {
|
||||
// Non-image: inserting file path.
|
||||
self.insert_selected_path(&sel_path);
|
||||
}
|
||||
(InputResult::None, false)
|
||||
// No selection: treat Enter as closing the popup/session.
|
||||
self.active_popup = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_image_path(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
||||
}
|
||||
|
||||
/// Extract the `@token` that the cursor is currently positioned on, if any.
|
||||
///
|
||||
/// The returned string **does not** include the leading `@`.
|
||||
@@ -527,6 +637,60 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
// Do not treat Enter as paste inside a slash-command context.
|
||||
let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_))
|
||||
|| self
|
||||
.textarea
|
||||
.text()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.starts_with('/');
|
||||
if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.paste_burst_buffer.push('\n');
|
||||
let now = Instant::now();
|
||||
// Keep the window alive so subsequent lines are captured too.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// If we have pending placeholder pastes, submit immediately to expand them.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
let mut text = self.textarea.text().to_string();
|
||||
self.textarea.set_text("");
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
if text.is_empty() {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
self.history.record_local_submission(&text);
|
||||
return (InputResult::Submitted(text), true);
|
||||
}
|
||||
|
||||
// During a paste-like burst, treat Enter as a newline instead of submit.
|
||||
let now = Instant::now();
|
||||
let tight_after_char = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL);
|
||||
let recent_after_char = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
let burst_by_count =
|
||||
recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS;
|
||||
let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until);
|
||||
|
||||
if tight_after_char || burst_by_count || in_burst_window {
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
self.textarea.set_text("");
|
||||
|
||||
@@ -538,12 +702,19 @@ impl ChatComposer {
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
if text.is_empty() {
|
||||
(InputResult::None, true)
|
||||
} else {
|
||||
self.history.record_local_submission(&text);
|
||||
(InputResult::Submitted(text), true)
|
||||
// Strip image placeholders from the submitted text; images are retrieved via take_recent_submission_images()
|
||||
for img in &self.attached_images {
|
||||
if text.contains(&img.placeholder) {
|
||||
text = text.replace(&img.placeholder, "");
|
||||
}
|
||||
}
|
||||
|
||||
text = text.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
@@ -551,17 +722,302 @@ impl ChatComposer {
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
// If we have a buffered non-bracketed paste burst and enough time has
|
||||
// elapsed since the last char, flush it before handling a new input.
|
||||
let now = Instant::now();
|
||||
let timed_out = self
|
||||
.last_plain_char_time
|
||||
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
||||
if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) {
|
||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
||||
self.in_paste_burst_mode = false;
|
||||
// Reuse normal paste path (handles large-paste placeholders).
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
|
||||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||||
if matches!(input.code, KeyCode::Enter)
|
||||
&& (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
|
||||
{
|
||||
self.paste_burst_buffer.push('\n');
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
..
|
||||
} = input
|
||||
{
|
||||
let has_ctrl_or_alt =
|
||||
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
||||
if !has_ctrl_or_alt {
|
||||
// Update burst heuristics.
|
||||
match self.last_plain_char_time {
|
||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||
self.consecutive_plain_char_burst =
|
||||
self.consecutive_plain_char_burst.saturating_add(1);
|
||||
}
|
||||
_ => {
|
||||
self.consecutive_plain_char_burst = 1;
|
||||
}
|
||||
}
|
||||
self.last_plain_char_time = Some(now);
|
||||
|
||||
// If we're already buffering, capture the char into the buffer.
|
||||
if self.in_paste_burst_mode {
|
||||
self.paste_burst_buffer.push(ch);
|
||||
// Keep the window alive while we receive the burst.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
} else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
||||
// Do not start burst buffering while typing a slash command (first line starts with '/').
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if first_line.starts_with('/') {
|
||||
// Keep heuristics but do not buffer.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
// Insert normally.
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// Begin buffering from this character onward.
|
||||
self.paste_burst_buffer.push(ch);
|
||||
self.in_paste_burst_mode = true;
|
||||
// Keep the window alive to continue capturing.
|
||||
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Not buffering: insert normally and continue.
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
return (InputResult::None, true);
|
||||
} else {
|
||||
// Modified char ends any burst: flush buffered content before applying.
|
||||
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
|
||||
let pasted = std::mem::take(&mut self.paste_burst_buffer);
|
||||
self.in_paste_burst_mode = false;
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Special handling for backspace on placeholders
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} = input
|
||||
&& self.try_remove_any_placeholder_at_cursor()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.text();
|
||||
|
||||
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
|
||||
let crossterm::event::KeyEvent {
|
||||
code, modifiers, ..
|
||||
} = input;
|
||||
match code {
|
||||
KeyCode::Char(_) => {
|
||||
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| modifiers.contains(KeyModifiers::ALT);
|
||||
if has_ctrl_or_alt {
|
||||
// Modified char: clear burst window.
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.paste_burst_until = None;
|
||||
self.in_paste_burst_mode = false;
|
||||
self.paste_burst_buffer.clear();
|
||||
}
|
||||
// Plain chars handled above.
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Keep burst window alive (supports blank lines in paste).
|
||||
}
|
||||
_ => {
|
||||
// Other keys: clear burst window and any buffer (after flushing earlier).
|
||||
self.consecutive_plain_char_burst = 0;
|
||||
self.last_plain_char_time = None;
|
||||
self.paste_burst_until = None;
|
||||
self.in_paste_burst_mode = false;
|
||||
// Do not clear paste_burst_buffer here; it should have been flushed above.
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any placeholders were removed and remove their corresponding pending pastes
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
// Keep attached images in proportion to how many matching placeholders exist in the text.
|
||||
// This handles duplicate placeholders that share the same visible label.
|
||||
if !self.attached_images.is_empty() {
|
||||
let mut needed: HashMap<String, usize> = HashMap::new();
|
||||
for img in &self.attached_images {
|
||||
needed
|
||||
.entry(img.placeholder.clone())
|
||||
.or_insert_with(|| text_after.matches(&img.placeholder).count());
|
||||
}
|
||||
|
||||
let mut used: HashMap<String, usize> = HashMap::new();
|
||||
let mut kept: Vec<AttachedImage> = Vec::with_capacity(self.attached_images.len());
|
||||
for img in self.attached_images.drain(..) {
|
||||
let total_needed = *needed.get(&img.placeholder).unwrap_or(&0);
|
||||
let used_count = used.entry(img.placeholder.clone()).or_insert(0);
|
||||
if *used_count < total_needed {
|
||||
kept.push(img);
|
||||
*used_count += 1;
|
||||
}
|
||||
}
|
||||
self.attached_images = kept;
|
||||
}
|
||||
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
/// Attempts to remove an image or paste placeholder if the cursor is at the end of one.
|
||||
/// Returns true if a placeholder was removed.
|
||||
fn try_remove_any_placeholder_at_cursor(&mut self) -> bool {
|
||||
let p = self.textarea.cursor();
|
||||
let text = self.textarea.text();
|
||||
|
||||
// Try image placeholders first
|
||||
let mut out: Option<(usize, String)> = None;
|
||||
// Detect if the cursor is at the end of any image placeholder.
|
||||
// If duplicates exist, remove the specific occurrence's mapping.
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p < ph.len() {
|
||||
continue;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text[start..p] != *ph {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count the number of occurrences of `ph` before `start`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < start {
|
||||
if let Some(found) = text[search_pos..start].find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the occ_before-th attached image that shares this placeholder label.
|
||||
out = if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
Some((remove_idx, ph.clone()))
|
||||
} else {
|
||||
Some((i, ph.clone()))
|
||||
};
|
||||
break;
|
||||
}
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of an image placeholder.
|
||||
// let result = 'out: {
|
||||
let out: Option<(usize, String)> = 'out: {
|
||||
for (i, img) in self.attached_images.iter().enumerate() {
|
||||
let ph = &img.placeholder;
|
||||
if p + ph.len() > text.len() {
|
||||
continue;
|
||||
}
|
||||
if &text[p..p + ph.len()] != ph {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count occurrences of `ph` before `p`.
|
||||
let mut occ_before = 0usize;
|
||||
let mut search_pos = 0usize;
|
||||
while search_pos < p {
|
||||
if let Some(found) = text[search_pos..p].find(ph) {
|
||||
occ_before += 1;
|
||||
search_pos += found + ph.len();
|
||||
} else {
|
||||
break 'out None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((remove_idx, _)) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img2)| img2.placeholder == *ph)
|
||||
.nth(occ_before)
|
||||
{
|
||||
break 'out Some((remove_idx, ph.clone()));
|
||||
} else {
|
||||
break 'out Some((i, ph.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((idx, placeholder)) = out {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then try pasted-content placeholders
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let start = p - ph.len();
|
||||
if text[start..p] == *ph {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p - placeholder.len()..p, "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also handle when the cursor is at the START of a pasted-content placeholder.
|
||||
if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if p + ph.len() > text.len() {
|
||||
return None;
|
||||
}
|
||||
if &text[p..p + ph.len()] == ph {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
self.textarea.replace_range(p..p + placeholder.len(), "");
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -739,12 +1195,17 @@ impl WidgetRef for &ChatComposer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::chat_composer::AttachedImage;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn test_current_at_token_basic_cases() {
|
||||
@@ -901,7 +1362,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -925,7 +1386,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -955,7 +1416,7 @@ mod tests {
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -977,7 +1438,7 @@ mod tests {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
Ok(t) => t,
|
||||
@@ -1033,9 +1494,8 @@ mod tests {
|
||||
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 (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1051,25 +1511,18 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// When a slash command is dispatched, the composer should not submit
|
||||
// literal text and should clear its textarea.
|
||||
// When a slash command is dispatched, the composer should return a
|
||||
// Command result (not submit literal text) and clear its textarea.
|
||||
match result {
|
||||
InputResult::None => {}
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/init'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
|
||||
// Verify a DispatchCommand event for the "init" command was sent.
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::DispatchCommand(cmd)) => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
}
|
||||
Ok(_other) => panic!("unexpected app event"),
|
||||
Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"),
|
||||
Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1078,7 +1531,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1099,9 +1552,8 @@ mod tests {
|
||||
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 (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1114,24 +1566,16 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::None => {}
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "mention");
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/mention'"),
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
composer.insert_str("@");
|
||||
assert_eq!(composer.textarea.text(), "@");
|
||||
}
|
||||
|
||||
@@ -1141,7 +1585,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1215,7 +1659,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1282,7 +1726,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1321,4 +1765,112 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Image attachment tests ---
|
||||
#[test]
|
||||
fn attach_image_and_submit_includes_image_paths() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
composer.attach_image(path.clone(), 32, 16, "PNG");
|
||||
composer.handle_paste(" hi".into());
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "hi"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(vec![path], imgs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_image_without_text_submits_empty_text_and_images() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = PathBuf::from("/tmp/image2.png");
|
||||
composer.attach_image(path.clone(), 10, 5, "PNG");
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert!(text.is_empty()),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!(imgs[0], path);
|
||||
assert!(composer.attached_images.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_placeholder_backspace_behaves_like_text_placeholder() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
let path = PathBuf::from("/tmp/image3.png");
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
composer.textarea.move_cursor_to_end_of_line(false);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(!composer.textarea.text().contains(&placeholder));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
|
||||
// Re-add and test backspace in middle: should break the placeholder string
|
||||
// and drop the image mapping (same as text placeholder behavior).
|
||||
composer.attach_image(path.clone(), 20, 10, "PNG");
|
||||
let placeholder2 = composer.attached_images[0].placeholder.clone();
|
||||
// Move cursor to roughly middle of placeholder
|
||||
if let Some(start_pos) = composer.textarea.text().find(&placeholder2) {
|
||||
let mid_pos = start_pos + (placeholder2.len() / 2);
|
||||
composer.textarea.set_cursor(mid_pos);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(!composer.textarea.text().contains(&placeholder2));
|
||||
assert!(composer.attached_images.is_empty());
|
||||
} else {
|
||||
panic!("Placeholder not found in textarea");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
|
||||
let path1 = PathBuf::from("/tmp/image_dup1.png");
|
||||
let path2 = PathBuf::from("/tmp/image_dup2.png");
|
||||
|
||||
composer.attach_image(path1.clone(), 10, 5, "PNG");
|
||||
// separate placeholders with a space for clarity
|
||||
composer.handle_paste(" ".into());
|
||||
composer.attach_image(path2.clone(), 10, 5, "PNG");
|
||||
|
||||
let ph = composer.attached_images[0].placeholder.clone();
|
||||
let text = composer.textarea.text().to_string();
|
||||
let start1 = text.find(&ph).expect("first placeholder present");
|
||||
let end1 = start1 + ph.len();
|
||||
composer.textarea.set_cursor(end1);
|
||||
|
||||
// Backspace should delete the first placeholder and its mapping.
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
let new_text = composer.textarea.text().to_string();
|
||||
assert_eq!(1, new_text.matches(&ph).count(), "one placeholder remains");
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: "[image 10x5 PNG]".to_string()
|
||||
}],
|
||||
composer.attached_images,
|
||||
"one image mapping remains"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn duplicate_submissions_are_not_recorded() {
|
||||
@@ -219,7 +219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn navigation_with_async_fetch() {
|
||||
let (tx, rx) = channel::<AppEvent>();
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
|
||||
let mut history = ChatComposerHistory::new();
|
||||
|
||||
@@ -105,8 +105,8 @@ impl ListSelectionView {
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for ListSelectionView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
@@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
@@ -39,15 +42,17 @@ pub(crate) use list_selection_view::SelectionItem;
|
||||
use status_indicator_view::StatusIndicatorView;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane<'a> {
|
||||
pub(crate) struct BottomPane {
|
||||
/// Composer is retained even when a BottomPaneView is displayed so the
|
||||
/// input state is retained when the view is closed.
|
||||
composer: ChatComposer,
|
||||
|
||||
/// If present, this is displayed instead of the `composer`.
|
||||
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
|
||||
active_view: Option<Box<dyn BottomPaneView>>,
|
||||
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
@@ -59,12 +64,13 @@ pub(crate) struct BottomPane<'a> {
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) has_input_focus: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) placeholder_text: String,
|
||||
}
|
||||
|
||||
impl BottomPane<'_> {
|
||||
impl BottomPane {
|
||||
const BOTTOM_PAD_LINES: u16 = 2;
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
let enhanced_keys_supported = params.enhanced_keys_supported;
|
||||
@@ -77,6 +83,7 @@ impl BottomPane<'_> {
|
||||
),
|
||||
active_view: None,
|
||||
app_event_tx: params.app_event_tx,
|
||||
frame_requester: params.frame_requester,
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
@@ -90,8 +97,31 @@ impl BottomPane<'_> {
|
||||
} else {
|
||||
self.composer.desired_height(width)
|
||||
};
|
||||
let top_pad = if self.active_view.is_none() || self.status_view_active {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
view_height
|
||||
.saturating_add(Self::BOTTOM_PAD_LINES)
|
||||
.saturating_add(top_pad)
|
||||
}
|
||||
|
||||
view_height.saturating_add(Self::BOTTOM_PAD_LINES)
|
||||
fn layout(&self, area: Rect) -> Rect {
|
||||
let top = if self.active_view.is_none() || self.status_view_active {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let [_, content, _] = Layout::vertical([
|
||||
Constraint::Max(top),
|
||||
Constraint::Min(1),
|
||||
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
@@ -99,10 +129,11 @@ impl BottomPane<'_> {
|
||||
// status indicator shown while a task is running, or approval modal).
|
||||
// In these states the textarea is not interactable, so we should not
|
||||
// show its caret.
|
||||
if self.active_view.is_some() {
|
||||
if self.active_view.is_some() || self.status_view_active {
|
||||
None
|
||||
} else {
|
||||
self.composer.cursor_pos(area)
|
||||
let content = self.layout(area);
|
||||
self.composer.cursor_pos(content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +144,10 @@ impl BottomPane<'_> {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
let mut v = StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
);
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
@@ -144,7 +178,10 @@ impl BottomPane<'_> {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
// Modal aborted but task still running – restore status indicator.
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
let mut v = StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
);
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
@@ -172,6 +209,17 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update the animated header shown to the left of the brackets in the
|
||||
/// status indicator (defaults to "Working"). This will update the active
|
||||
/// StatusIndicatorView if present; otherwise, if a live overlay is active,
|
||||
/// it will update that. If neither is present, this call is a no-op.
|
||||
pub(crate) fn update_status_header(&mut self, header: String) {
|
||||
if let Some(view) = self.active_view.as_mut() {
|
||||
view.update_status_header(header.clone());
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
self.composer
|
||||
@@ -199,6 +247,7 @@ impl BottomPane<'_> {
|
||||
if self.active_view.is_none() {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
)));
|
||||
self.status_view_active = true;
|
||||
}
|
||||
@@ -292,7 +341,7 @@ impl BottomPane<'_> {
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
// --- History helpers ---
|
||||
@@ -320,35 +369,34 @@ impl BottomPane<'_> {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
if self.active_view.is_none() {
|
||||
self.composer
|
||||
.attach_image(path, width, height, format_label);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
self.composer.take_recent_submission_images()
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
impl WidgetRef for &BottomPane {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let content = self.layout(area);
|
||||
|
||||
if let Some(view) = &self.active_view {
|
||||
// Reserve bottom padding lines; keep at least 1 line for the view.
|
||||
let avail = area.height;
|
||||
if avail > 0 {
|
||||
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
|
||||
let view_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: avail - pad,
|
||||
};
|
||||
view.render(view_rect, buf);
|
||||
}
|
||||
view.render(content, buf);
|
||||
} else {
|
||||
let avail = area.height;
|
||||
if avail > 0 {
|
||||
let composer_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
// Reserve bottom padding
|
||||
height: avail - BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1)),
|
||||
};
|
||||
(&self.composer).render_ref(composer_rect, buf);
|
||||
}
|
||||
(&self.composer).render_ref(content, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +407,7 @@ mod tests {
|
||||
use crate::app_event::AppEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -371,10 +419,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -389,10 +438,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn overlay_not_shown_above_approval_modal() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -418,10 +468,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn composer_not_shown_after_denied_if_task_running() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -448,18 +499,16 @@ mod tests {
|
||||
assert!(pane.active_view.is_some(), "active view should be present");
|
||||
|
||||
// Render and ensure the top row includes the Working header instead of the composer.
|
||||
// Give the animation thread a moment to tick.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
let mut row0 = String::new();
|
||||
let mut row1 = String::new();
|
||||
for x in 0..area.width {
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header after denial: {row0:?}"
|
||||
row1.contains("Working"),
|
||||
"expected Working header after denial on row 1: {row1:?}"
|
||||
);
|
||||
|
||||
// Drain the channel to avoid unused warnings.
|
||||
@@ -468,10 +517,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_indicator_visible_during_command_execution() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -480,17 +530,13 @@ mod tests {
|
||||
// Begin a task: show initial status.
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Allow some frames so the animation thread ticks.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
|
||||
// Render and confirm the line contains the "Working" header.
|
||||
let area = Rect::new(0, 0, 40, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
let mut row0 = String::new();
|
||||
for x in 0..area.width {
|
||||
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
@@ -500,10 +546,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_present_for_status_view() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -522,12 +569,12 @@ mod tests {
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
// Top row contains the status header
|
||||
// Row 1 contains the status header (row 0 is the spacer)
|
||||
let mut top = String::new();
|
||||
for x in 0..area.width {
|
||||
top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert_eq!(buf[(0, 1)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert!(
|
||||
top.contains("Working"),
|
||||
"expected Working header on top row: {top:?}"
|
||||
@@ -552,10 +599,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_shrinks_when_tiny() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -563,7 +611,7 @@ mod tests {
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner.
|
||||
// Height=2 → with spacer, spinner on row 1; no bottom padding.
|
||||
let area2 = Rect::new(0, 0, 20, 2);
|
||||
let mut buf2 = Buffer::empty(area2);
|
||||
(&pane).render_ref(area2, &mut buf2);
|
||||
@@ -573,13 +621,10 @@ mod tests {
|
||||
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(row0.trim().is_empty(), "expected spacer on row 0: {row0:?}");
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header on row 0: {row0:?}"
|
||||
);
|
||||
assert!(
|
||||
row1.trim().is_empty(),
|
||||
"expected bottom padding on row 1: {row1:?}"
|
||||
row1.contains("Working"),
|
||||
"expected Working on row 1: {row1:?}"
|
||||
);
|
||||
|
||||
// Height=1 → no padding; single row is the spinner.
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
use super::BottomPaneView;
|
||||
|
||||
@@ -14,18 +15,26 @@ pub(crate) struct StatusIndicatorView {
|
||||
}
|
||||
|
||||
impl StatusIndicatorView {
|
||||
pub fn new(app_event_tx: AppEventSender) -> Self {
|
||||
pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
view: StatusIndicatorWidget::new(app_event_tx),
|
||||
view: StatusIndicatorWidget::new(app_event_tx, frame_requester),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_text(&mut self, text: String) {
|
||||
self.view.update_text(text);
|
||||
}
|
||||
|
||||
pub fn update_header(&mut self, header: String) {
|
||||
self.view.update_header(header);
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
impl BottomPaneView for StatusIndicatorView {
|
||||
fn update_status_header(&mut self, header: String) {
|
||||
self.update_header(header);
|
||||
}
|
||||
|
||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -38,7 +47,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
self.view.render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
if key_event.code == KeyCode::Esc {
|
||||
self.view.interrupt();
|
||||
}
|
||||
|
||||
@@ -1473,7 +1473,7 @@ mod tests {
|
||||
.timestamp() as u64;
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed);
|
||||
|
||||
for _case in 0..10_000 {
|
||||
for _case in 0..500 {
|
||||
let mut ta = TextArea::new();
|
||||
let mut state = TextAreaState::default();
|
||||
// Track element payloads we insert. Payloads use characters '[' and ']' which
|
||||
@@ -1497,7 +1497,7 @@ mod tests {
|
||||
let mut width: u16 = rng.random_range(1..=12);
|
||||
let mut height: u16 = rng.random_range(1..=4);
|
||||
|
||||
for _step in 0..200 {
|
||||
for _step in 0..60 {
|
||||
// Mostly stable width/height, occasionally change
|
||||
if rng.random_bool(0.1) {
|
||||
width = rng.random_range(1..=12);
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
@@ -47,11 +48,14 @@ use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui::FrameRequester;
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
mod interrupts;
|
||||
@@ -60,9 +64,13 @@ mod agent;
|
||||
use self::agent::spawn_agent;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_common::model_presets::builtin_model_presets;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use uuid::Uuid;
|
||||
@@ -73,10 +81,10 @@ struct RunningCommand {
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
bottom_pane: BottomPane,
|
||||
active_exec_cell: Option<ExecCell>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
@@ -84,8 +92,6 @@ pub(crate) struct ChatWidget<'a> {
|
||||
last_token_usage: TokenUsage,
|
||||
// Stream lifecycle controller
|
||||
stream: StreamController,
|
||||
// Track the most recently active stream kind in the current turn
|
||||
last_stream_kind: Option<StreamKind>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
|
||||
task_complete_pending: bool,
|
||||
@@ -93,7 +99,13 @@ pub(crate) struct ChatWidget<'a> {
|
||||
interrupts: InterruptManager,
|
||||
// Whether a redraw is needed after handling the current event
|
||||
needs_redraw: bool,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
reasoning_buffer: String,
|
||||
// Accumulates full reasoning content for transcript-only recording
|
||||
full_reasoning_buffer: String,
|
||||
session_id: Option<Uuid>,
|
||||
frame_requester: FrameRequester,
|
||||
last_history_was_exec: bool,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -101,8 +113,6 @@ struct UserMessage {
|
||||
image_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
use crate::streaming::StreamKind;
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
@@ -120,21 +130,21 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
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);
|
||||
let _ = self.stream.finalize(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.session_id = Some(event.session_id);
|
||||
self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
|
||||
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
@@ -144,30 +154,48 @@ impl ChatWidget<'_> {
|
||||
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.last_stream_kind = Some(StreamKind::Answer);
|
||||
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);
|
||||
self.handle_streaming_delta(delta);
|
||||
}
|
||||
|
||||
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
||||
self.handle_streaming_delta(StreamKind::Reasoning, delta);
|
||||
// For reasoning deltas, do not stream to history. Accumulate the
|
||||
// current reasoning block and extract the first bold element
|
||||
// (between **/**) as the chunk header. Show this header as status.
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
// Update the shimmer header to the extracted reasoning chunk header.
|
||||
self.bottom_pane.update_status_header(header);
|
||||
} else {
|
||||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||
}
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
fn on_agent_reasoning_final(&mut self, text: String) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
let finished = self.stream.apply_final_reasoning(&text, &sink);
|
||||
self.last_stream_kind = Some(StreamKind::Reasoning);
|
||||
self.handle_if_stream_finished(finished);
|
||||
fn on_agent_reasoning_final(&mut self) {
|
||||
// At the end of a reasoning block, record transcript-only content.
|
||||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||||
if !self.full_reasoning_buffer.is_empty() {
|
||||
self.add_to_history(history_cell::new_reasoning_block(
|
||||
self.full_reasoning_buffer.clone(),
|
||||
&self.config,
|
||||
));
|
||||
}
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
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);
|
||||
// Start a new reasoning block for header extraction and accumulate transcript.
|
||||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||||
self.full_reasoning_buffer.push_str("\n\n");
|
||||
self.reasoning_buffer.clear();
|
||||
}
|
||||
|
||||
// Raw reasoning uses the same flow as summarized reasoning
|
||||
@@ -176,7 +204,8 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.stream.reset_headers_for_new_turn();
|
||||
self.last_stream_kind = None;
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
@@ -185,9 +214,7 @@ impl ChatWidget<'_> {
|
||||
// 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);
|
||||
}
|
||||
let _ = self.stream.finalize(true, &sink);
|
||||
}
|
||||
// Mark task stopped and request redraw now that all content is in history.
|
||||
self.bottom_pane.set_task_running(false);
|
||||
@@ -206,7 +233,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_error(&mut self, message: String) {
|
||||
self.add_to_history(&history_cell::new_error_event(message));
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.running_commands.clear();
|
||||
self.stream.clear_all();
|
||||
@@ -214,7 +241,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
|
||||
self.add_to_history(&history_cell::new_plan_update(update));
|
||||
self.add_to_history(history_cell::new_plan_update(update));
|
||||
}
|
||||
|
||||
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||
@@ -249,7 +276,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||||
self.add_to_history(&history_cell::new_patch_event(
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: event.auto_approved,
|
||||
},
|
||||
@@ -304,6 +331,12 @@ impl ChatWidget<'_> {
|
||||
fn on_background_event(&mut self, message: String) {
|
||||
debug!("BackgroundEvent: {message}");
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String) {
|
||||
// Show stream errors in the transcript so users see retry/backoff info.
|
||||
self.add_to_history(history_cell::new_stream_error_event(message));
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
/// 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) {
|
||||
@@ -344,15 +377,17 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.task_complete_pending = false;
|
||||
}
|
||||
// A completed stream indicates non-exec content was just inserted.
|
||||
// Reset the exec header grouping so the next exec shows its header.
|
||||
self.last_history_was_exec = false;
|
||||
self.flush_interrupt_queue();
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
|
||||
fn handle_streaming_delta(&mut self, delta: String) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
self.stream.begin(kind, &sink);
|
||||
self.last_stream_kind = Some(kind);
|
||||
self.stream.begin(&sink);
|
||||
self.stream.push_and_maybe_commit(&delta, &sink);
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
@@ -370,6 +405,7 @@ impl ChatWidget<'_> {
|
||||
exit_code: ev.exit_code,
|
||||
stdout: ev.stdout.clone(),
|
||||
stderr: ev.stderr.clone(),
|
||||
formatted_output: ev.formatted_output.clone(),
|
||||
},
|
||||
));
|
||||
|
||||
@@ -377,9 +413,16 @@ impl ChatWidget<'_> {
|
||||
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(&history_cell::new_completed_exec_command(
|
||||
command, parsed, output,
|
||||
));
|
||||
let include_header = !self.last_history_was_exec;
|
||||
let cell = history_cell::new_completed_exec_command(
|
||||
command,
|
||||
parsed,
|
||||
output,
|
||||
include_header,
|
||||
ev.duration,
|
||||
);
|
||||
self.add_to_history(cell);
|
||||
self.last_history_was_exec = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,9 +432,9 @@ impl ChatWidget<'_> {
|
||||
event: codex_core::protocol::PatchApplyEndEvent,
|
||||
) {
|
||||
if event.success {
|
||||
self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
|
||||
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
|
||||
} else {
|
||||
self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
|
||||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +456,7 @@ impl ChatWidget<'_> {
|
||||
ev: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(&history_cell::new_patch_event(
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
PatchEventType::ApprovalRequest,
|
||||
ev.changes.clone(),
|
||||
));
|
||||
@@ -442,9 +485,11 @@ impl ChatWidget<'_> {
|
||||
exec.parsed.extend(ev.parsed_cmd);
|
||||
}
|
||||
_ => {
|
||||
let include_header = !self.last_history_was_exec;
|
||||
self.active_exec_cell = Some(history_cell::new_active_exec_command(
|
||||
ev.command,
|
||||
ev.parsed_cmd,
|
||||
include_header,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -455,11 +500,11 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
|
||||
self.add_to_history(history_cell::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(&*history_cell::new_completed_mcp_tool_call(
|
||||
self.add_boxed_history(history_cell::new_completed_mcp_tool_call(
|
||||
80,
|
||||
ev.invocation,
|
||||
ev.duration,
|
||||
@@ -496,6 +541,7 @@ impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
frame_requester: FrameRequester,
|
||||
app_event_tx: AppEventSender,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -507,8 +553,10 @@ impl ChatWidget<'_> {
|
||||
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
@@ -523,13 +571,15 @@ impl ChatWidget<'_> {
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
stream: StreamController::new(config),
|
||||
last_stream_kind: None,
|
||||
running_commands: HashMap::new(),
|
||||
pending_exec_completions: Vec::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
needs_redraw: false,
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
last_history_was_exec: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,27 +598,156 @@ impl ChatWidget<'_> {
|
||||
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
self.submit_user_message(text.into());
|
||||
let images = self.bottom_pane.take_recent_submission_images();
|
||||
self.submit_user_message(UserMessage {
|
||||
text,
|
||||
image_paths: images,
|
||||
});
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &str,
|
||||
) {
|
||||
tracing::info!(
|
||||
"attach_image path={path:?} width={width} height={height} format={format_label}",
|
||||
);
|
||||
self.bottom_pane
|
||||
.attach_image(path.clone(), width, height, format_label);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||||
match cmd {
|
||||
SlashCommand::New => {
|
||||
self.app_event_tx.send(AppEvent::NewSession);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
self.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
self.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
SlashCommand::Approvals => {
|
||||
self.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_login::logout(&self.config.codex_home) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let text = match get_git_diff().await {
|
||||
Ok((is_git_repo, diff_text)) => {
|
||||
if is_git_repo {
|
||||
diff_text
|
||||
} else {
|
||||
"`/diff` — _not inside a git repository_".to_string()
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Failed to compute diff: {e}"),
|
||||
};
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::Mention => {
|
||||
self.insert_str("@");
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
self.add_status_output();
|
||||
}
|
||||
SlashCommand::Mcp => {
|
||||
self.add_mcp_output();
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
id: "1".to_string(),
|
||||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
// call_id: "1".to_string(),
|
||||
// command: vec!["git".into(), "apply".into()],
|
||||
// cwd: self.config.cwd.clone(),
|
||||
// reason: Some("test".to_string()),
|
||||
// }),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
call_id: "1".to_string(),
|
||||
changes: HashMap::from([
|
||||
(
|
||||
PathBuf::from("/tmp/test.txt"),
|
||||
FileChange::Add {
|
||||
content: "test".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("/tmp/test2.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: "+test\n-test2".to_string(),
|
||||
move_path: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
reason: None,
|
||||
grant_root: Some(PathBuf::from("/tmp")),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
fn flush_active_exec_cell(&mut self) {
|
||||
if let Some(active) = self.active_exec_cell.take() {
|
||||
self.last_history_was_exec = true;
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistory(active.display_lines()));
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(active)));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_to_history(&mut self, cell: &dyn HistoryCell) {
|
||||
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
|
||||
// Only break exec grouping if the cell renders visible lines.
|
||||
let has_display_lines = !cell.display_lines().is_empty();
|
||||
self.flush_active_exec_cell();
|
||||
if has_display_lines {
|
||||
self.last_history_was_exec = false;
|
||||
}
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistory(cell.display_lines()));
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
}
|
||||
|
||||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||
self.flush_active_exec_cell();
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
@@ -604,7 +783,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Only show the text portion in conversation history.
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(&history_cell::new_user_prompt(text.clone()));
|
||||
self.add_to_history(history_cell::new_user_prompt(text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,9 +811,9 @@ impl ChatWidget<'_> {
|
||||
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
|
||||
delta,
|
||||
}) => self.on_agent_reasoning_delta(delta),
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text })
|
||||
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
self.on_agent_reasoning_final(text)
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { .. })
|
||||
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
|
||||
self.on_agent_reasoning_final()
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||
EventMsg::TaskStarted => self.on_task_started(),
|
||||
@@ -657,8 +836,41 @@ impl ChatWidget<'_> {
|
||||
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
// Also show background logs in the transcript for visibility.
|
||||
self.add_to_history(history_cell::new_log_line(message.clone()));
|
||||
self.on_background_event(message)
|
||||
}
|
||||
EventMsg::SubagentBegin(ev) => {
|
||||
let msg = format!("subagent begin: {} ({})", ev.name, ev.subagent_id);
|
||||
self.add_to_history(history_cell::new_log_line(msg));
|
||||
}
|
||||
EventMsg::SubagentForwarded(ev) => {
|
||||
// Summarize forwarded event type; include message text when it is AgentMessage.
|
||||
match *ev.event {
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
let msg = format!("subagent {}: {}", ev.name, message);
|
||||
self.add_to_history(history_cell::new_log_line(msg));
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { ref delta }) => {
|
||||
let msg = format!("subagent {}: {}", ev.name, delta);
|
||||
self.add_to_history(history_cell::new_log_line(msg));
|
||||
}
|
||||
ref other => {
|
||||
let msg = format!("subagent {} forwarded: {:?}", ev.name, other);
|
||||
self.add_to_history(history_cell::new_log_line(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::SubagentEnd(ev) => {
|
||||
let summary = ev.last_agent_message.as_deref().unwrap_or("");
|
||||
let msg = format!(
|
||||
"subagent end: {} ({}) success={} {}",
|
||||
ev.name, ev.subagent_id, ev.success, summary
|
||||
);
|
||||
self.add_to_history(history_cell::new_log_line(msg));
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
|
||||
EventMsg::ConversationHistory(_) => {}
|
||||
}
|
||||
// Coalesce redraws: issue at most one after handling the event
|
||||
if self.needs_redraw {
|
||||
@@ -668,7 +880,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_in_progress(&mut self) {
|
||||
@@ -678,14 +890,13 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||
pub(crate) fn on_diff_complete(&mut self) {
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.add_to_history(&history_cell::new_diff_output(diff_output));
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
self.add_to_history(&history_cell::new_status_output(
|
||||
self.add_to_history(history_cell::new_status_output(
|
||||
&self.config,
|
||||
&self.total_token_usage,
|
||||
&self.session_id,
|
||||
@@ -733,6 +944,57 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||
pub(crate) fn open_approvals_popup(&mut self) {
|
||||
let current_approval = self.config.approval_policy;
|
||||
let current_sandbox = self.config.sandbox_policy.clone();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
current_approval == preset.approval && current_sandbox == preset.sandbox;
|
||||
let approval = preset.approval;
|
||||
let sandbox = preset.sandbox.clone();
|
||||
let name = preset.label.to_string();
|
||||
let description = Some(preset.description.to_string());
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(approval),
|
||||
sandbox_policy: Some(sandbox.clone()),
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
}));
|
||||
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
|
||||
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));
|
||||
})];
|
||||
items.push(SelectionItem {
|
||||
name,
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Select Approval Mode".to_string(),
|
||||
None,
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||
self.config.approval_policy = policy;
|
||||
}
|
||||
|
||||
/// Set the sandbox policy in the widget's config copy.
|
||||
pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) {
|
||||
self.config.sandbox_policy = policy;
|
||||
}
|
||||
|
||||
/// Set the reasoning effort in the widget's config copy.
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: ReasoningEffortConfig) {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
@@ -745,7 +1007,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn add_mcp_output(&mut self) {
|
||||
if self.config.mcp_servers.is_empty() {
|
||||
self.add_to_history(&history_cell::empty_mcp_output());
|
||||
self.add_to_history(history_cell::empty_mcp_output());
|
||||
} else {
|
||||
self.submit_op(Op::ListMcpTools);
|
||||
}
|
||||
@@ -793,7 +1055,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
|
||||
self.add_to_history(&history_cell::new_mcp_tools_output(&self.config, ev.tools));
|
||||
self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools));
|
||||
}
|
||||
|
||||
/// Programmatically submit a user text message as if typed in the
|
||||
@@ -825,7 +1087,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
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);
|
||||
@@ -872,5 +1134,35 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the first bold (Markdown) element in the form **...** from `s`.
|
||||
// Returns the inner text if found; otherwise `None`.
|
||||
fn extract_first_bold(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'*' && bytes[i + 1] == b'*' {
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j + 1 < bytes.len() {
|
||||
if bytes[j] == b'*' && bytes[j + 1] == b'*' {
|
||||
// Found closing **
|
||||
let inner = &s[start..j];
|
||||
let trimmed = inner.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// No closing; stop searching (wait for more deltas)
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -71,7 +71,7 @@ impl InterruptManager {
|
||||
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
|
||||
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),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 886
|
||||
expression: combined
|
||||
---
|
||||
thinking
|
||||
I will first analyze the request.
|
||||
|
||||
codex
|
||||
Here is the result.
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
thinking
|
||||
I will first analyze the request.
|
||||
|
||||
codex
|
||||
Here is the result.
|
||||
|
||||
@@ -19,7 +19,9 @@ use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_login::CodexAuth;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -30,7 +32,6 @@ 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 {
|
||||
@@ -43,9 +44,34 @@ fn test_config() -> Config {
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
// Backward-compat shim for older session logs that predate the
|
||||
// `formatted_output` field on ExecCommandEnd events.
|
||||
fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json::Value {
|
||||
if let Some(obj) = payload.as_object_mut()
|
||||
&& let Some(msg) = obj.get_mut("msg")
|
||||
&& let Some(m) = msg.as_object_mut()
|
||||
{
|
||||
let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if ty == "exec_command_end" && !m.contains_key("formatted_output") {
|
||||
let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let formatted = if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
m.insert(
|
||||
"formatted_output".to_string(),
|
||||
serde_json::Value::String(formatted),
|
||||
);
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_answer_without_newline_is_flushed_immediately() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
@@ -73,7 +99,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
});
|
||||
|
||||
// Drain history insertions and verify the final line is present.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.iter().any(|lines| {
|
||||
let s = lines
|
||||
@@ -101,27 +127,38 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
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);
|
||||
let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key(
|
||||
"test",
|
||||
)));
|
||||
let mut w = ChatWidget::new(
|
||||
cfg,
|
||||
conversation_manager,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
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<AppEvent>,
|
||||
ChatWidget,
|
||||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||||
) {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||||
let app_event_tx = AppEventSender::new(tx_raw);
|
||||
let (op_tx, op_rx) = unbounded_channel::<Op>();
|
||||
let cfg = test_config();
|
||||
let bottom = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -136,24 +173,29 @@ fn make_chatwidget_manual() -> (
|
||||
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,
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
last_history_was_exec: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
fn drain_insert_history(
|
||||
rx: &std::sync::mpsc::Receiver<AppEvent>,
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Vec<Vec<ratatui::text::Line<'static>>> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
out.push(lines);
|
||||
match ev {
|
||||
AppEvent::InsertHistoryLines(lines) => out.push(lines),
|
||||
AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -196,7 +238,7 @@ fn open_fixture(name: &str) -> std::fs::File {
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_shows_working_then_completed() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -221,12 +263,14 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
call_id: "call-1".into(),
|
||||
stdout: "done".into(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "done".into(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: "done".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
@@ -234,14 +278,18 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
);
|
||||
let blob = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
blob.contains("Completed"),
|
||||
"expected completed exec cell to show Completed header: {blob:?}"
|
||||
blob.contains('✓'),
|
||||
"expected completed exec cell to show success marker: {blob:?}"
|
||||
);
|
||||
assert!(
|
||||
blob.contains("echo done"),
|
||||
"expected command text to be present: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_shows_working_then_failed() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -266,12 +314,14 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
call_id: "call-2".into(),
|
||||
stdout: String::new(),
|
||||
stderr: "error".into(),
|
||||
aggregated_output: "error".into(),
|
||||
exit_code: 2,
|
||||
duration: std::time::Duration::from_millis(7),
|
||||
formatted_output: "".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
@@ -279,14 +329,87 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
);
|
||||
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:?}"
|
||||
blob.contains('✗'),
|
||||
"expected failure marker present: {blob:?}"
|
||||
);
|
||||
assert!(
|
||||
blob.contains("false"),
|
||||
"expected command text present: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_extends_previous_when_consecutive() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// First command
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-a".into(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "call-a".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo one".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd: vec![
|
||||
codex_core::parse_command::ParsedCommand::Unknown {
|
||||
cmd: "echo one".into(),
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-a".into(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "call-a".into(),
|
||||
stdout: "one".into(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "one".into(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: "one".into(),
|
||||
}),
|
||||
});
|
||||
let first_cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(first_cells.len(), 1, "first exec should insert history");
|
||||
|
||||
// Second command
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-b".into(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "call-b".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo two".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd: vec![
|
||||
codex_core::parse_command::ParsedCommand::Unknown {
|
||||
cmd: "echo two".into(),
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-b".into(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "call-b".into(),
|
||||
stdout: "two".into(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "two".into(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: "two".into(),
|
||||
}),
|
||||
});
|
||||
let second_cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(second_cells.len(), 1, "second exec should extend history");
|
||||
let first_blob = lines_to_single_string(&first_cells[0]);
|
||||
let second_blob = lines_to_single_string(&second_cells[0]);
|
||||
assert!(first_blob.contains('✓'));
|
||||
assert!(second_blob.contains("echo two"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
@@ -324,16 +447,30 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
match kind {
|
||||
"codex_event" => {
|
||||
if let Some(payload) = v.get("payload") {
|
||||
let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
|
||||
let ev: Event =
|
||||
serde_json::from_value(upgrade_event_payload_for_tests(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,
|
||||
);
|
||||
match app_ev {
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let lines = cell.display_lines();
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,13 +481,25 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
{
|
||||
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,
|
||||
);
|
||||
match app_ev {
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let lines = cell.display_lines();
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,6 +515,11 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
.expect("read ideal-binary-response.txt");
|
||||
// Normalize line endings for Windows vs. Unix checkouts
|
||||
let ideal = ideal.replace("\r\n", "\n");
|
||||
let ideal_first_line = ideal
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// 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.
|
||||
@@ -391,22 +545,68 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
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.
|
||||
// Compare only after the last session banner marker. Skip the transient
|
||||
// 'thinking' header if present, and start from the first non-empty line
|
||||
// of content that follows.
|
||||
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");
|
||||
// Anchor to the first ideal line if present; otherwise use heuristics.
|
||||
let start_idx = (last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start() == ideal_first_line)
|
||||
.or_else(|| {
|
||||
// Prefer the first assistant content line (blockquote '>' prefix) after the marker.
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start().starts_with('>'))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: first non-empty, non-'thinking' line
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| {
|
||||
let t = lines[idx].trim_start();
|
||||
!t.is_empty() && t != "thinking"
|
||||
})
|
||||
.expect("no content line found after marker")
|
||||
});
|
||||
|
||||
let mut compare_lines: Vec<String> = 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());
|
||||
// Ensure the first line is trimmed-left to match the fixture shape.
|
||||
compare_lines.push(lines[start_idx].trim_start().to_string());
|
||||
compare_lines.extend(lines[(start_idx + 1)..].iter().cloned());
|
||||
let visible_after = compare_lines.join("\n");
|
||||
|
||||
// Normalize: drop a leading 'thinking' line if present in either side to
|
||||
// avoid coupling to whether the reasoning header is rendered in history.
|
||||
fn drop_leading_thinking(s: &str) -> String {
|
||||
let mut it = s.lines();
|
||||
let first = it.next();
|
||||
let rest = it.collect::<Vec<_>>().join("\n");
|
||||
if first.is_some_and(|l| l.trim() == "thinking") {
|
||||
rest
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
let visible_after = drop_leading_thinking(&visible_after);
|
||||
let ideal = drop_leading_thinking(&ideal);
|
||||
|
||||
// Normalize: strip leading Markdown blockquote markers ('>' or '> ') which
|
||||
// may be present in rendered transcript lines but not in the ideal text.
|
||||
fn strip_blockquotes(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| {
|
||||
l.strip_prefix("> ")
|
||||
.or_else(|| l.strip_prefix('>'))
|
||||
.unwrap_or(l)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
let visible_after = strip_blockquotes(&visible_after);
|
||||
let ideal = strip_blockquotes(&ideal);
|
||||
|
||||
// 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"));
|
||||
@@ -423,7 +623,7 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_events_emit_history_cells() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Approval request -> proposed patch summary cell
|
||||
let mut changes = HashMap::new();
|
||||
@@ -443,7 +643,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -468,7 +668,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyBegin(begin),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected applying patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -487,7 +687,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyEnd(end),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected applied patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -498,7 +698,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_approval_sends_op_with_submission_id() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut 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(
|
||||
@@ -539,7 +739,7 @@ fn apply_patch_approval_sends_op_with_submission_id() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_full_flow_integration_like() {
|
||||
let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Backend requests approval
|
||||
let mut changes = HashMap::new();
|
||||
@@ -655,7 +855,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_request_shows_diff_summary() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut 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;
|
||||
@@ -680,7 +880,7 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
});
|
||||
|
||||
// Drain history insertions and verify the diff summary is present
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
!cells.is_empty(),
|
||||
"expected a history cell with the proposed patch summary"
|
||||
@@ -695,14 +895,14 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
|
||||
// Per-file summary line should include the file path and counts
|
||||
assert!(
|
||||
blob.contains("README.md (+2 -0)"),
|
||||
blob.contains("README.md"),
|
||||
"missing per-file diff summary: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_renders_history_cell() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
let update = UpdatePlanArgs {
|
||||
explanation: Some("Adapting plan".to_string()),
|
||||
plan: vec![
|
||||
@@ -724,7 +924,7 @@ fn plan_update_renders_history_cell() {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::PlanUpdate(update),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected plan update cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -737,8 +937,27 @@ fn plan_update_renders_history_cell() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
fn stream_error_is_rendered_to_history() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…";
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::StreamError(StreamErrorEvent {
|
||||
message: msg.to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected a history cell for StreamError");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(blob.contains("⚠ "));
|
||||
assert!(blob.contains("stream error:"));
|
||||
assert!(blob.contains("idle timeout waiting for SSE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Answer: no header until a newline commit
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -749,7 +968,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
});
|
||||
let mut saw_codex_pre = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -777,7 +996,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
chat.on_commit_tick();
|
||||
let mut saw_codex_post = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -795,8 +1014,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
"expected 'codex' header to be emitted after first newline commit"
|
||||
);
|
||||
|
||||
// Reasoning: header immediately
|
||||
let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
|
||||
// Reasoning: do NOT emit a history header; status text is updated instead
|
||||
let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
|
||||
chat2.handle_codex_event(Event {
|
||||
id: "sub-b".into(),
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
@@ -805,7 +1024,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
});
|
||||
let mut saw_thinking = false;
|
||||
while let Ok(ev) = rx2.try_recv() {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -819,14 +1038,14 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_thinking,
|
||||
"expected 'thinking' header to be emitted at stream start"
|
||||
!saw_thinking,
|
||||
"reasoning deltas should not emit history headers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin turn
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -858,7 +1077,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let mut header_count = 0usize;
|
||||
let mut combined = String::new();
|
||||
for lines in &cells {
|
||||
@@ -894,7 +1113,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
|
||||
#[test]
|
||||
fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// No deltas; only final reasoning followed by final message.
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -911,7 +1130,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
});
|
||||
|
||||
// Drain history and snapshot the combined visible content.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
@@ -921,7 +1140,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
|
||||
#[test]
|
||||
fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Stream some reasoning deltas first.
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -972,7 +1191,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
|
||||
// Snapshot the combined visible content to ensure we render as expected
|
||||
// when deltas are followed by the identical final message.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user