mirror of
https://github.com/openai/codex.git
synced 2026-05-17 17:53:06 +00:00
Compare commits
30 Commits
rust-v0.29
...
easong/lis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eaaf307e1 | ||
|
|
18330c2362 | ||
|
|
4c46490e53 | ||
|
|
5c1416d99b | ||
|
|
0525b48baa | ||
|
|
1f4f9cde8e | ||
|
|
cad37009e1 | ||
|
|
e2b3053b2b | ||
|
|
e47bd33689 | ||
|
|
6b878bea01 | ||
|
|
ca46510fd3 | ||
|
|
6efb52e545 | ||
|
|
d84a799ec0 | ||
|
|
c8fab51372 | ||
|
|
58d77ca4e7 | ||
|
|
0269096229 | ||
|
|
70a6d4b1b4 | ||
|
|
b1d5f7c0bd | ||
|
|
066c6cce02 | ||
|
|
bd65f81e54 | ||
|
|
ba9620aea7 | ||
|
|
45c3b20041 | ||
|
|
6cfc012e9d | ||
|
|
17a80d43c8 | ||
|
|
c11696f6b1 | ||
|
|
5775174ec2 | ||
|
|
ba631e7928 | ||
|
|
db3834733a | ||
|
|
d6182becbe | ||
|
|
323a5cb7e7 |
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@@ -14,33 +14,18 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.8.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Run all tasks using workspace filters
|
||||
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -37,7 +37,15 @@ See `codex-rs/tui/styles.md`.
|
||||
- Avoid churn: don’t refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow file‑local conventions and do not introduce type annotations solely to satisfy .into().
|
||||
- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines.
|
||||
|
||||
## Snapshot tests
|
||||
### Text wrapping
|
||||
- Always use textwrap::wrap to wrap plain strings.
|
||||
- If you have a ratatui Line and you want to wrap it, use the helpers in tui/src/wrapping.rs, e.g. word_wrap_lines / word_wrap_line.
|
||||
- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic.
|
||||
- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils.
|
||||
|
||||
## Tests
|
||||
|
||||
### Snapshot tests
|
||||
|
||||
This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows:
|
||||
|
||||
@@ -52,3 +60,7 @@ This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to va
|
||||
|
||||
If you don’t have the tool:
|
||||
- `cargo install cargo-insta`
|
||||
|
||||
### Test assertions
|
||||
|
||||
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
|
||||
|
||||
@@ -75,7 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored
|
||||
- [CLI usage](./docs/getting-started.md#cli-usage)
|
||||
- [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input)
|
||||
- [Example prompts](./docs/getting-started.md#example-prompts)
|
||||
- [Memory with AGENTS.md](./docs/getting-started.md#memory--project-docs)
|
||||
- [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)
|
||||
- [Configuration](./docs/config.md)
|
||||
- [**Sandbox & approvals**](./docs/sandbox.md)
|
||||
- [**Authentication**](./docs/authentication.md)
|
||||
|
||||
154
codex-rs/Cargo.lock
generated
154
codex-rs/Cargo.lock
generated
@@ -547,9 +547,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.45"
|
||||
version = "4.5.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318"
|
||||
checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -557,9 +557,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.44"
|
||||
version = "4.5.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8"
|
||||
checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -579,9 +579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.45"
|
||||
version = "4.5.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6"
|
||||
checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -917,6 +917,8 @@ name = "codex-protocol"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"mcp-types",
|
||||
"mime_guess",
|
||||
"pretty_assertions",
|
||||
@@ -926,6 +928,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"sys-locale",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
@@ -1707,6 +1710,26 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||
dependencies = [
|
||||
"fax_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fax_derive"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fd-lock"
|
||||
version = "4.0.4"
|
||||
@@ -1738,6 +1761,17 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35943d22b2f19c0cb198ecf915910a8158e94541c89dcc63300d7799d46c2c5e"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.4.2"
|
||||
@@ -2237,6 +2271,45 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fec61c43fdc4e368a9f450272833123a8ef0d7083a44597660ce94d791b8a2e2"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_decimal_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal_data"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b70963bc35f9bdf1bc66a5c1f458f4991c1dc71760e00fa06016b2c76b2738d5"
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ae5921528335e91da1b6c695dbf1ec37df5ac13faa3f91e5640be93aa2fbefd"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_locale_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.0.0"
|
||||
@@ -2250,6 +2323,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_data"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fdef0c124749d06a743c69e938350816554eb63ac979166590e2b4ee4252765"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.0.0"
|
||||
@@ -2361,9 +2440,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.6"
|
||||
version = "0.25.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
|
||||
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
@@ -2371,6 +2450,7 @@ dependencies = [
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
@@ -2434,9 +2514,9 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.43.1"
|
||||
version = "1.43.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
|
||||
checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0"
|
||||
dependencies = [
|
||||
"console",
|
||||
"once_cell",
|
||||
@@ -2624,12 +2704,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-decoder"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
@@ -2928,6 +3002,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
@@ -3436,11 +3520,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags 2.9.1",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -3489,6 +3573,7 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3609,6 +3694,15 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f55f4fedc84ed39cb7a489322318976425e42a147e2be79d8f878e2884f94e84"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
@@ -4816,6 +4910,15 @@ dependencies = [
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
@@ -4976,13 +5079,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
|
||||
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"jpeg-decoder",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5354,9 +5460,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.8"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d7b8994f367f16e6fa14b5aebbcb350de5d7cbea82dc5b00ae997dd71680dd2"
|
||||
checksum = "ccd2a058a86cfece0bf96f7cce1021efef9c8ed0e892ab74639173e5ed7a34fa"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
|
||||
@@ -18,7 +18,7 @@ workspace = true
|
||||
anyhow = "1"
|
||||
similar = "2.7.0"
|
||||
thiserror = "2.0.16"
|
||||
tree-sitter = "0.25.8"
|
||||
tree-sitter = "0.25.9"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
once_cell = "1"
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
|
||||
/// `codex-linux-sandbox` we *directly* execute
|
||||
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
|
||||
///
|
||||
/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the
|
||||
/// environment before creating any threads.
|
||||
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
|
||||
/// 2. Construct a Tokio multi-thread runtime.
|
||||
/// 3. Derive the path to the current executable (so children can re-invoke the
|
||||
/// sandbox) when running on Linux.
|
||||
@@ -106,7 +105,7 @@ where
|
||||
|
||||
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
|
||||
|
||||
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
|
||||
/// Load env vars from ~/.codex/.env.
|
||||
///
|
||||
/// Security: Do not allow `.env` files to create or modify any variables
|
||||
/// with names starting with `CODEX_`.
|
||||
@@ -116,10 +115,6 @@ fn load_dotenv() {
|
||||
{
|
||||
set_filtered(iter);
|
||||
}
|
||||
|
||||
if let Ok(iter) = dotenvy::dotenv_iter() {
|
||||
set_filtered(iter);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.
|
||||
|
||||
@@ -53,7 +53,7 @@ tokio-util = "0.7.16"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.23.4"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tree-sitter = "0.25.8"
|
||||
tree-sitter = "0.25.9"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
whoami = "1.6.1"
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use crate::AuthManager;
|
||||
use bytes::Bytes;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::prelude::*;
|
||||
use regex_lite::Regex;
|
||||
@@ -19,7 +20,6 @@ use tokio_util::io::ReaderStream;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::chat_completions::AggregateStreamExt;
|
||||
use crate::chat_completions::stream_chat_completions;
|
||||
@@ -70,7 +70,7 @@ pub struct ModelClient {
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
client: reqwest::Client,
|
||||
provider: ModelProviderInfo,
|
||||
session_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
}
|
||||
@@ -82,7 +82,7 @@ impl ModelClient {
|
||||
provider: ModelProviderInfo,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
session_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
) -> Self {
|
||||
let client = create_client(&config.responses_originator_header);
|
||||
|
||||
@@ -91,7 +91,7 @@ impl ModelClient {
|
||||
auth_manager,
|
||||
client,
|
||||
provider,
|
||||
session_id,
|
||||
conversation_id,
|
||||
effort,
|
||||
summary,
|
||||
}
|
||||
@@ -157,14 +157,6 @@ impl ModelClient {
|
||||
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
|
||||
let auth_mode = auth_manager
|
||||
.as_ref()
|
||||
.and_then(|m| m.auth())
|
||||
.as_ref()
|
||||
.map(|a| a.mode);
|
||||
|
||||
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
|
||||
|
||||
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
|
||||
let tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
let reasoning = create_reasoning_param_for_request(
|
||||
@@ -173,9 +165,7 @@ impl ModelClient {
|
||||
self.summary,
|
||||
);
|
||||
|
||||
// Request encrypted COT if we are not storing responses,
|
||||
// otherwise reasoning items will be referenced by ID
|
||||
let include: Vec<String> = if !store && reasoning.is_some() {
|
||||
let include: Vec<String> = if reasoning.is_some() {
|
||||
vec!["reasoning.encrypted_content".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
@@ -204,10 +194,10 @@ impl ModelClient {
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning,
|
||||
store,
|
||||
store: false,
|
||||
stream: true,
|
||||
include,
|
||||
prompt_cache_key: Some(self.session_id.to_string()),
|
||||
prompt_cache_key: Some(self.conversation_id.to_string()),
|
||||
text,
|
||||
};
|
||||
|
||||
@@ -233,7 +223,9 @@ impl ModelClient {
|
||||
|
||||
req_builder = req_builder
|
||||
.header("OpenAI-Beta", "responses=experimental")
|
||||
.header("session_id", self.session_id.to_string())
|
||||
// Send session_id for compatibility.
|
||||
.header("conversation_id", self.conversation_id.to_string())
|
||||
.header("session_id", self.conversation_id.to_string())
|
||||
.header(reqwest::header::ACCEPT, "text/event-stream")
|
||||
.json(&payload);
|
||||
|
||||
@@ -408,9 +400,15 @@ impl From<ResponseCompletedUsage> for TokenUsage {
|
||||
fn from(val: ResponseCompletedUsage) -> Self {
|
||||
TokenUsage {
|
||||
input_tokens: val.input_tokens,
|
||||
cached_input_tokens: val.input_tokens_details.map(|d| d.cached_tokens),
|
||||
cached_input_tokens: val
|
||||
.input_tokens_details
|
||||
.map(|d| d.cached_tokens)
|
||||
.unwrap_or(0),
|
||||
output_tokens: val.output_tokens,
|
||||
reasoning_output_tokens: val.output_tokens_details.map(|d| d.reasoning_tokens),
|
||||
reasoning_output_tokens: val
|
||||
.output_tokens_details
|
||||
.map(|d| d.reasoning_tokens)
|
||||
.unwrap_or(0),
|
||||
total_tokens: val.total_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ pub struct Prompt {
|
||||
/// Conversation context input items.
|
||||
pub input: Vec<ResponseItem>,
|
||||
|
||||
/// Whether to store response on server side (disable_response_storage = !store).
|
||||
pub store: bool,
|
||||
|
||||
/// Tools available to the model, including additional tools sourced from
|
||||
/// external MCP servers.
|
||||
pub(crate) tools: Vec<OpenAiTool>,
|
||||
@@ -128,7 +125,6 @@ pub(crate) struct ResponsesApiRequest<'a> {
|
||||
pub(crate) tool_choice: &'static str,
|
||||
pub(crate) parallel_tool_calls: bool,
|
||||
pub(crate) reasoning: Option<Reasoning>,
|
||||
/// true when using the Responses API.
|
||||
pub(crate) store: bool,
|
||||
pub(crate) stream: bool,
|
||||
pub(crate) include: Vec<String>,
|
||||
@@ -199,7 +195,7 @@ mod tests {
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
@@ -229,7 +225,7 @@ mod tests {
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
|
||||
@@ -15,6 +15,7 @@ use async_channel::Sender;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::MaybeApplyPatchVerified;
|
||||
use codex_apply_patch::maybe_parse_apply_patch_verified;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::protocol::ConversationHistoryResponseEvent;
|
||||
use codex_protocol::protocol::TaskStartedEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -30,7 +31,6 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::apply_patch;
|
||||
@@ -99,9 +99,11 @@ use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::protocol::StreamErrorEvent;
|
||||
use crate::protocol::Submission;
|
||||
use crate::protocol::TaskCompleteEvent;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WebSearchBeginEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_command_safety;
|
||||
use crate::safety::assess_safety_for_untrusted_command;
|
||||
@@ -148,7 +150,7 @@ pub struct Codex {
|
||||
/// unique session id.
|
||||
pub struct CodexSpawnOk {
|
||||
pub codex: Codex,
|
||||
pub session_id: Uuid,
|
||||
pub conversation_id: ConversationId,
|
||||
}
|
||||
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
@@ -184,7 +186,6 @@ impl Codex {
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
disable_response_storage: config.disable_response_storage,
|
||||
notify: config.notify.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
};
|
||||
@@ -205,7 +206,7 @@ impl Codex {
|
||||
session
|
||||
.record_initial_history(&turn_context, conversation_history)
|
||||
.await;
|
||||
let session_id = session.session_id;
|
||||
let conversation_id = session.conversation_id;
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
tokio::spawn(submission_loop(
|
||||
@@ -220,7 +221,10 @@ impl Codex {
|
||||
rx_event,
|
||||
};
|
||||
|
||||
Ok(CodexSpawnOk { codex, session_id })
|
||||
Ok(CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||||
@@ -262,13 +266,14 @@ struct State {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
history: ConversationHistory,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
}
|
||||
|
||||
/// Context for an initialized model agent
|
||||
///
|
||||
/// A session has at most 1 running task at a time, and can be interrupted by user input.
|
||||
pub(crate) struct Session {
|
||||
session_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
tx_event: Sender<Event>,
|
||||
|
||||
/// Manager for external MCP servers/tools.
|
||||
@@ -301,7 +306,6 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) approval_policy: AskForApproval,
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
|
||||
pub(crate) disable_response_storage: bool,
|
||||
pub(crate) tools_config: ToolsConfig,
|
||||
}
|
||||
|
||||
@@ -334,8 +338,6 @@ struct ConfigureSession {
|
||||
approval_policy: AskForApproval,
|
||||
/// How to sandbox commands executed in the system
|
||||
sandbox_policy: SandboxPolicy,
|
||||
/// Disable server-side response storage (send full context each request)
|
||||
disable_response_storage: bool,
|
||||
|
||||
/// Optional external notifier command tokens. Present only when the
|
||||
/// client wants the agent to spawn a program after each completed
|
||||
@@ -360,7 +362,6 @@ impl Session {
|
||||
tx_event: Sender<Event>,
|
||||
initial_history: InitialHistory,
|
||||
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
|
||||
let session_id = Uuid::new_v4();
|
||||
let ConfigureSession {
|
||||
provider,
|
||||
model,
|
||||
@@ -370,7 +371,6 @@ impl Session {
|
||||
base_instructions,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
notify,
|
||||
cwd,
|
||||
} = configure_session;
|
||||
@@ -379,6 +379,20 @@ impl Session {
|
||||
return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}"));
|
||||
}
|
||||
|
||||
let (conversation_id, rollout_params) = match &initial_history {
|
||||
InitialHistory::New | InitialHistory::Forked(_) => {
|
||||
let conversation_id = ConversationId::default();
|
||||
(
|
||||
conversation_id,
|
||||
RolloutRecorderParams::new(conversation_id, user_instructions.clone()),
|
||||
)
|
||||
}
|
||||
InitialHistory::Resumed(resumed_history) => (
|
||||
resumed_history.conversation_id,
|
||||
RolloutRecorderParams::resume(resumed_history.rollout_path.clone()),
|
||||
),
|
||||
};
|
||||
|
||||
// Error messages to dispatch after SessionConfigured is sent.
|
||||
let mut post_session_configured_error_events = Vec::<Event>::new();
|
||||
|
||||
@@ -388,7 +402,7 @@ impl Session {
|
||||
// - spin up MCP connection manager
|
||||
// - perform default shell discovery
|
||||
// - load history metadata
|
||||
let rollout_fut = RolloutRecorder::new(&config, session_id, user_instructions.clone());
|
||||
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
|
||||
|
||||
let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone());
|
||||
let default_shell_fut = shell::default_user_shell();
|
||||
@@ -434,7 +448,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// Now that `session_id` is final (may have been updated by resume),
|
||||
// Now that the conversation id is final (may have been updated by resume),
|
||||
// construct the model client.
|
||||
let client = ModelClient::new(
|
||||
config.clone(),
|
||||
@@ -442,7 +456,7 @@ impl Session {
|
||||
provider.clone(),
|
||||
model_reasoning_effort,
|
||||
model_reasoning_summary,
|
||||
session_id,
|
||||
conversation_id,
|
||||
);
|
||||
let turn_context = TurnContext {
|
||||
client,
|
||||
@@ -462,10 +476,9 @@ impl Session {
|
||||
sandbox_policy,
|
||||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
disable_response_storage,
|
||||
};
|
||||
let sess = Arc::new(Session {
|
||||
session_id,
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
mcp_connection_manager,
|
||||
session_manager: ExecSessionManager::default(),
|
||||
@@ -481,13 +494,16 @@ impl Session {
|
||||
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
|
||||
let initial_messages = match &initial_history {
|
||||
InitialHistory::New => None,
|
||||
InitialHistory::Resumed(items) => Some(sess.build_initial_messages(items)),
|
||||
InitialHistory::Forked(items) => Some(sess.build_initial_messages(items)),
|
||||
InitialHistory::Resumed(resumed_history) => {
|
||||
Some(sess.build_initial_messages(&resumed_history.history))
|
||||
}
|
||||
};
|
||||
|
||||
let events = std::iter::once(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id,
|
||||
session_id: conversation_id,
|
||||
model,
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
@@ -530,8 +546,12 @@ impl Session {
|
||||
InitialHistory::New => {
|
||||
self.record_initial_history_new(turn_context).await;
|
||||
}
|
||||
InitialHistory::Resumed(items) => {
|
||||
self.record_initial_history_resumed(items).await;
|
||||
InitialHistory::Forked(items) => {
|
||||
self.record_initial_history_from_items(items).await;
|
||||
}
|
||||
InitialHistory::Resumed(resumed_history) => {
|
||||
self.record_initial_history_from_items(resumed_history.history)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,8 +573,8 @@ impl Session {
|
||||
self.record_conversation_items(&conversation_items).await;
|
||||
}
|
||||
|
||||
async fn record_initial_history_resumed(&self, items: Vec<ResponseItem>) {
|
||||
self.record_conversation_items(&items).await;
|
||||
async fn record_initial_history_from_items(&self, items: Vec<ResponseItem>) {
|
||||
self.record_conversation_items_internal(&items, false).await;
|
||||
}
|
||||
|
||||
/// build the initial messages vector for SessionConfigured by converting
|
||||
@@ -663,8 +683,14 @@ impl Session {
|
||||
/// Records items to both the rollout and the chat completions/ZDR
|
||||
/// transcript, if enabled.
|
||||
async fn record_conversation_items(&self, items: &[ResponseItem]) {
|
||||
self.record_conversation_items_internal(items, true).await;
|
||||
}
|
||||
|
||||
async fn record_conversation_items_internal(&self, items: &[ResponseItem], persist: bool) {
|
||||
debug!("Recording items for conversation: {items:?}");
|
||||
self.record_state_snapshot(items).await;
|
||||
if persist {
|
||||
self.record_state_snapshot(items).await;
|
||||
}
|
||||
|
||||
self.state.lock_unchecked().history.record_items(items);
|
||||
}
|
||||
@@ -1088,7 +1114,7 @@ async fn submission_loop(
|
||||
provider,
|
||||
effective_effort,
|
||||
effective_summary,
|
||||
sess.session_id,
|
||||
sess.conversation_id,
|
||||
);
|
||||
|
||||
let new_approval_policy = approval_policy.unwrap_or(prev.approval_policy);
|
||||
@@ -1117,7 +1143,6 @@ async fn submission_loop(
|
||||
sandbox_policy: new_sandbox_policy.clone(),
|
||||
shell_environment_policy: prev.shell_environment_policy.clone(),
|
||||
cwd: new_cwd.clone(),
|
||||
disable_response_storage: prev.disable_response_storage,
|
||||
};
|
||||
|
||||
// Install the new persistent context for subsequent tasks/turns.
|
||||
@@ -1177,7 +1202,7 @@ async fn submission_loop(
|
||||
provider,
|
||||
effort,
|
||||
summary,
|
||||
sess.session_id,
|
||||
sess.conversation_id,
|
||||
);
|
||||
|
||||
let fresh_turn_context = TurnContext {
|
||||
@@ -1199,7 +1224,6 @@ async fn submission_loop(
|
||||
sandbox_policy,
|
||||
shell_environment_policy: turn_context.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
disable_response_storage: turn_context.disable_response_storage,
|
||||
};
|
||||
// TODO: record the new environment context in the conversation history
|
||||
// no current task, spawn a new one with the per‑turn context
|
||||
@@ -1221,7 +1245,7 @@ async fn submission_loop(
|
||||
other => sess.notify_approval(&id, other),
|
||||
},
|
||||
Op::AddToHistory { text } => {
|
||||
let id = sess.session_id;
|
||||
let id = sess.conversation_id;
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await
|
||||
@@ -1252,7 +1276,7 @@ async fn submission_loop(
|
||||
log_id,
|
||||
entry: entry_opt.map(|e| {
|
||||
codex_protocol::message_history::HistoryEntry {
|
||||
session_id: e.session_id,
|
||||
conversation_id: e.session_id,
|
||||
ts: e.ts,
|
||||
text: e.text,
|
||||
}
|
||||
@@ -1358,7 +1382,7 @@ async fn submission_loop(
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::ConversationHistory(ConversationHistoryResponseEvent {
|
||||
conversation_id: sess.session_id,
|
||||
conversation_id: sess.conversation_id,
|
||||
entries: sess.state.lock_unchecked().history.contents(),
|
||||
}),
|
||||
};
|
||||
@@ -1604,7 +1628,6 @@ async fn run_turn(
|
||||
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
store: !turn_context.disable_response_storage,
|
||||
tools,
|
||||
base_instructions_override: turn_context.base_instructions.clone(),
|
||||
};
|
||||
@@ -1776,15 +1799,23 @@ async fn try_run_turn(
|
||||
response_id: _,
|
||||
token_usage,
|
||||
} => {
|
||||
if let Some(token_usage) = token_usage {
|
||||
sess.tx_event
|
||||
.send(Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::TokenCount(token_usage),
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
let info = {
|
||||
let mut st = sess.state.lock_unchecked();
|
||||
let info = TokenUsageInfo::new_or_append(
|
||||
&st.token_info,
|
||||
&token_usage,
|
||||
turn_context.client.get_model_context_window(),
|
||||
);
|
||||
st.token_info = info.clone();
|
||||
info
|
||||
};
|
||||
sess.tx_event
|
||||
.send(Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||||
if let Ok(Some(unified_diff)) = unified_diff {
|
||||
@@ -1858,7 +1889,6 @@ async fn run_compact_task(
|
||||
|
||||
let prompt = Prompt {
|
||||
input: turn_input,
|
||||
store: !turn_context.disable_response_storage,
|
||||
tools: Vec::new(),
|
||||
base_instructions_override: Some(compact_instructions.clone()),
|
||||
};
|
||||
@@ -2851,13 +2881,21 @@ async fn drain_to_completed(
|
||||
response_id: _,
|
||||
token_usage,
|
||||
}) => {
|
||||
// some providers don't return token usage, so we default
|
||||
// TODO: consider approximate token usage
|
||||
let token_usage = token_usage.unwrap_or_default();
|
||||
let info = {
|
||||
let mut st = sess.state.lock_unchecked();
|
||||
let info = TokenUsageInfo::new_or_append(
|
||||
&st.token_info,
|
||||
&token_usage,
|
||||
turn_context.client.get_model_context_window(),
|
||||
);
|
||||
st.token_info = info.clone();
|
||||
info
|
||||
};
|
||||
|
||||
sess.tx_event
|
||||
.send(Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::TokenCount(token_usage),
|
||||
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
@@ -78,11 +78,6 @@ pub struct Config {
|
||||
/// Defaults to `false`.
|
||||
pub show_raw_agent_reasoning: bool,
|
||||
|
||||
/// Disable server-side response storage (sends the full conversation
|
||||
/// context with every request). Currently necessary for OpenAI customers
|
||||
/// who have opted into Zero Data Retention (ZDR).
|
||||
pub disable_response_storage: bool,
|
||||
|
||||
/// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<String>,
|
||||
|
||||
@@ -417,11 +412,6 @@ pub struct ConfigToml {
|
||||
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
|
||||
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
|
||||
|
||||
/// Disable server-side response storage (sends the full conversation
|
||||
/// context with every request). Currently necessary for OpenAI customers
|
||||
/// who have opted into Zero Data Retention (ZDR).
|
||||
pub disable_response_storage: Option<bool>,
|
||||
|
||||
/// Optional external command to spawn for end-user notifications.
|
||||
#[serde(default)]
|
||||
pub notify: Option<Vec<String>>,
|
||||
@@ -640,7 +630,6 @@ pub struct ConfigOverrides {
|
||||
pub include_plan_tool: Option<bool>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_view_image_tool: Option<bool>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub tools_web_search_request: Option<bool>,
|
||||
}
|
||||
@@ -668,7 +657,6 @@ impl Config {
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_view_image_tool,
|
||||
disable_response_storage,
|
||||
show_raw_agent_reasoning,
|
||||
tools_web_search_request: override_tools_web_search_request,
|
||||
} = overrides;
|
||||
@@ -744,23 +732,24 @@ impl Config {
|
||||
.or(config_profile.model)
|
||||
.or(cfg.model)
|
||||
.unwrap_or_else(default_model);
|
||||
let model_family = find_family_for_model(&model).unwrap_or_else(|| {
|
||||
let supports_reasoning_summaries =
|
||||
cfg.model_supports_reasoning_summaries.unwrap_or(false);
|
||||
let reasoning_summary_format = cfg
|
||||
.model_reasoning_summary_format
|
||||
.unwrap_or(ReasoningSummaryFormat::None);
|
||||
ModelFamily {
|
||||
slug: model.clone(),
|
||||
family: model.clone(),
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries,
|
||||
reasoning_summary_format,
|
||||
uses_local_shell_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
}
|
||||
|
||||
let mut model_family = find_family_for_model(&model).unwrap_or_else(|| ModelFamily {
|
||||
slug: model.clone(),
|
||||
family: model.clone(),
|
||||
needs_special_apply_patch_instructions: false,
|
||||
supports_reasoning_summaries: false,
|
||||
reasoning_summary_format: ReasoningSummaryFormat::None,
|
||||
uses_local_shell_tool: false,
|
||||
apply_patch_tool_type: None,
|
||||
});
|
||||
|
||||
if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries {
|
||||
model_family.supports_reasoning_summaries = supports_reasoning_summaries;
|
||||
}
|
||||
if let Some(model_reasoning_summary_format) = cfg.model_reasoning_summary_format {
|
||||
model_family.reasoning_summary_format = model_reasoning_summary_format;
|
||||
}
|
||||
|
||||
let openai_model_info = get_model_info(&model_family);
|
||||
let model_context_window = cfg
|
||||
.model_context_window
|
||||
@@ -802,11 +791,6 @@ impl Config {
|
||||
.unwrap_or_else(AskForApproval::default),
|
||||
sandbox_policy,
|
||||
shell_environment_policy,
|
||||
disable_response_storage: config_profile
|
||||
.disable_response_storage
|
||||
.or(cfg.disable_response_storage)
|
||||
.or(disable_response_storage)
|
||||
.unwrap_or(false),
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
@@ -1071,7 +1055,6 @@ exclude_slash_tmp = true
|
||||
let toml = r#"
|
||||
model = "o3"
|
||||
approval_policy = "untrusted"
|
||||
disable_response_storage = false
|
||||
|
||||
# Can be used to determine which profile to use if not specified by
|
||||
# `ConfigOverrides`.
|
||||
@@ -1101,7 +1084,6 @@ model_provider = "openai-chat-completions"
|
||||
model = "o3"
|
||||
model_provider = "openai"
|
||||
approval_policy = "on-failure"
|
||||
disable_response_storage = true
|
||||
|
||||
[profiles.gpt5]
|
||||
model = "gpt-5"
|
||||
@@ -1199,7 +1181,6 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -1257,7 +1238,6 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -1330,7 +1310,6 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -1389,7 +1368,6 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
|
||||
@@ -15,7 +15,6 @@ pub struct ConfigProfile {
|
||||
/// [`ModelProviderInfo`] to use.
|
||||
pub model_provider: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
@@ -29,7 +28,6 @@ impl From<ConfigProfile> for codex_protocol::mcp_protocol::Profile {
|
||||
model: config_profile.model,
|
||||
model_provider: config_profile.model_provider,
|
||||
approval_policy: config_profile.approval_policy,
|
||||
disable_response_storage: config_profile.disable_response_storage,
|
||||
model_reasoning_effort: config_profile.model_reasoning_effort,
|
||||
model_reasoning_summary: config_profile.model_reasoning_summary,
|
||||
model_verbosity: config_profile.model_verbosity,
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct McpServerConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
|
||||
/// Startup timeout in milliseconds for initializing MCP server & initially listing tools.
|
||||
#[serde(default)]
|
||||
pub startup_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
use crate::codex::INITIAL_SUBMIT_ID;
|
||||
@@ -18,18 +11,31 @@ use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResumedHistory {
|
||||
pub conversation_id: ConversationId,
|
||||
pub history: Vec<ResponseItem>,
|
||||
pub rollout_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum InitialHistory {
|
||||
New,
|
||||
Resumed(Vec<ResponseItem>),
|
||||
Resumed(ResumedHistory),
|
||||
Forked(Vec<ResponseItem>),
|
||||
}
|
||||
|
||||
/// Represents a newly created Codex conversation, including the first event
|
||||
/// (which is [`EventMsg::SessionConfigured`]).
|
||||
pub struct NewConversation {
|
||||
pub conversation_id: Uuid,
|
||||
pub conversation_id: ConversationId,
|
||||
pub conversation: Arc<CodexConversation>,
|
||||
pub session_configured: SessionConfiguredEvent,
|
||||
}
|
||||
@@ -37,7 +43,7 @@ pub struct NewConversation {
|
||||
/// [`ConversationManager`] is responsible for creating conversations and
|
||||
/// maintaining them in memory.
|
||||
pub struct ConversationManager {
|
||||
conversations: Arc<RwLock<HashMap<Uuid, Arc<CodexConversation>>>>,
|
||||
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
@@ -70,14 +76,14 @@ impl ConversationManager {
|
||||
let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?;
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, initial_history).await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
} else {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
} = { Codex::spawn(config, auth_manager, InitialHistory::New).await? };
|
||||
conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, InitialHistory::New).await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
}
|
||||
}
|
||||
@@ -85,7 +91,7 @@ impl ConversationManager {
|
||||
async fn finalize_spawn(
|
||||
&self,
|
||||
codex: Codex,
|
||||
conversation_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
) -> CodexResult<NewConversation> {
|
||||
// The first event must be `SessionInitialized`. Validate and forward it
|
||||
// to the caller so that they can display it in the conversation
|
||||
@@ -116,7 +122,7 @@ impl ConversationManager {
|
||||
|
||||
pub async fn get_conversation(
|
||||
&self,
|
||||
conversation_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
) -> CodexResult<Arc<CodexConversation>> {
|
||||
let conversations = self.conversations.read().await;
|
||||
conversations
|
||||
@@ -134,12 +140,12 @@ impl ConversationManager {
|
||||
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, initial_history).await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_conversation(&self, conversation_id: Uuid) {
|
||||
pub async fn remove_conversation(&self, conversation_id: ConversationId) {
|
||||
self.conversations.write().await.remove(&conversation_id);
|
||||
}
|
||||
|
||||
@@ -161,7 +167,7 @@ impl ConversationManager {
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
session_id: conversation_id,
|
||||
conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, history).await?;
|
||||
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
@@ -172,7 +178,7 @@ impl ConversationManager {
|
||||
/// and all items that follow them.
|
||||
fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) -> InitialHistory {
|
||||
if n == 0 {
|
||||
return InitialHistory::Resumed(items);
|
||||
return InitialHistory::Forked(items);
|
||||
}
|
||||
|
||||
// Walk backwards counting only `user` Message items, find cut index.
|
||||
@@ -194,7 +200,7 @@ fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) ->
|
||||
// No prefix remains after dropping; start a new conversation.
|
||||
InitialHistory::New
|
||||
} else {
|
||||
InitialHistory::Resumed(items.into_iter().take(cut_index).collect())
|
||||
InitialHistory::Forked(items.into_iter().take(cut_index).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +258,7 @@ mod tests {
|
||||
let truncated = truncate_after_dropping_last_messages(items.clone(), 1);
|
||||
assert_eq!(
|
||||
truncated,
|
||||
InitialHistory::Resumed(vec![items[0].clone(), items[1].clone(), items[2].clone(),])
|
||||
InitialHistory::Forked(vec![items[0].clone(), items[1].clone(), items[2].clone(),])
|
||||
);
|
||||
|
||||
let truncated2 = truncate_after_dropping_last_messages(items, 2);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::task::JoinError;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CodexErr>;
|
||||
|
||||
@@ -49,7 +49,7 @@ pub enum CodexErr {
|
||||
Stream(String, Option<Duration>),
|
||||
|
||||
#[error("no conversation with id: {0}")]
|
||||
ConversationNotFound(Uuid),
|
||||
ConversationNotFound(ConversationId),
|
||||
|
||||
#[error("session configured event was not the first event in the stream")]
|
||||
SessionConfiguredNotFirstEvent,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
mod apply_patch;
|
||||
pub mod auth;
|
||||
mod bash;
|
||||
pub mod bash;
|
||||
mod chat_completions;
|
||||
mod client;
|
||||
mod client_common;
|
||||
@@ -62,6 +62,7 @@ pub mod terminal;
|
||||
mod tool_apply_patch;
|
||||
pub mod turn_diff_tracker;
|
||||
pub use rollout::RolloutRecorder;
|
||||
pub use rollout::SessionMeta;
|
||||
pub use rollout::list::ConversationItem;
|
||||
pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -36,8 +37,8 @@ use crate::config_types::McpServerConfig;
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
|
||||
/// Timeout for the `tools/list` request.
|
||||
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Default timeout for initializing MCP server & initially listing tools.
|
||||
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Map that holds a startup error for every MCP server that could **not** be
|
||||
/// spawned successfully.
|
||||
@@ -81,6 +82,11 @@ struct ToolInfo {
|
||||
tool: Tool,
|
||||
}
|
||||
|
||||
struct ManagedClient {
|
||||
client: Arc<McpClient>,
|
||||
startup_timeout: Duration,
|
||||
}
|
||||
|
||||
/// A thin wrapper around a set of running [`McpClient`] instances.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct McpConnectionManager {
|
||||
@@ -88,7 +94,7 @@ pub(crate) struct McpConnectionManager {
|
||||
///
|
||||
/// The server name originates from the keys of the `mcp_servers` map in
|
||||
/// the user configuration.
|
||||
clients: HashMap<String, std::sync::Arc<McpClient>>,
|
||||
clients: HashMap<String, ManagedClient>,
|
||||
|
||||
/// Fully qualified tool name -> tool instance.
|
||||
tools: HashMap<String, ToolInfo>,
|
||||
@@ -126,8 +132,15 @@ impl McpConnectionManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
let startup_timeout = cfg
|
||||
.startup_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
|
||||
join_set.spawn(async move {
|
||||
let McpServerConfig { command, args, env } = cfg;
|
||||
let McpServerConfig {
|
||||
command, args, env, ..
|
||||
} = cfg;
|
||||
let client_res = McpClient::new_stdio_client(
|
||||
command.into(),
|
||||
args.into_iter().map(OsString::from).collect(),
|
||||
@@ -154,12 +167,15 @@ impl McpConnectionManager {
|
||||
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(),
|
||||
};
|
||||
let initialize_notification_params = None;
|
||||
let timeout = Some(Duration::from_secs(10));
|
||||
match client
|
||||
.initialize(params, initialize_notification_params, timeout)
|
||||
.initialize(
|
||||
params,
|
||||
initialize_notification_params,
|
||||
Some(startup_timeout),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_response) => (server_name, Ok(client)),
|
||||
Ok(_response) => (server_name, Ok((client, startup_timeout))),
|
||||
Err(e) => (server_name, Err(e)),
|
||||
}
|
||||
}
|
||||
@@ -168,15 +184,26 @@ impl McpConnectionManager {
|
||||
});
|
||||
}
|
||||
|
||||
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
|
||||
HashMap::with_capacity(join_set.len());
|
||||
let mut clients: HashMap<String, ManagedClient> = HashMap::with_capacity(join_set.len());
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
let (server_name, client_res) = res?; // JoinError propagation
|
||||
let (server_name, client_res) = match res {
|
||||
Ok((server_name, client_res)) => (server_name, client_res),
|
||||
Err(e) => {
|
||||
warn!("Task panic when starting MCP server: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match client_res {
|
||||
Ok(client) => {
|
||||
clients.insert(server_name, std::sync::Arc::new(client));
|
||||
Ok((client, startup_timeout)) => {
|
||||
clients.insert(
|
||||
server_name,
|
||||
ManagedClient {
|
||||
client: Arc::new(client),
|
||||
startup_timeout,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
errors.insert(server_name, e);
|
||||
@@ -184,7 +211,13 @@ impl McpConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
let all_tools = list_all_tools(&clients).await?;
|
||||
let all_tools = match list_all_tools(&clients).await {
|
||||
Ok(tools) => tools,
|
||||
Err(e) => {
|
||||
warn!("Failed to list tools from some MCP servers: {e:#}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let tools = qualify_tools(all_tools);
|
||||
|
||||
@@ -212,6 +245,7 @@ impl McpConnectionManager {
|
||||
.clients
|
||||
.get(server)
|
||||
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?
|
||||
.client
|
||||
.clone();
|
||||
|
||||
client
|
||||
@@ -229,21 +263,18 @@ impl McpConnectionManager {
|
||||
|
||||
/// Query every server for its available tools and return a single map that
|
||||
/// contains **all** tools. Each key is the fully-qualified name for the tool.
|
||||
async fn list_all_tools(
|
||||
clients: &HashMap<String, std::sync::Arc<McpClient>>,
|
||||
) -> Result<Vec<ToolInfo>> {
|
||||
async fn list_all_tools(clients: &HashMap<String, ManagedClient>) -> Result<Vec<ToolInfo>> {
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
// Spawn one task per server so we can query them concurrently. This
|
||||
// keeps the overall latency roughly at the slowest server instead of
|
||||
// the cumulative latency.
|
||||
for (server_name, client) in clients {
|
||||
for (server_name, managed_client) in clients {
|
||||
let server_name_cloned = server_name.clone();
|
||||
let client_clone = client.clone();
|
||||
let client_clone = managed_client.client.clone();
|
||||
let startup_timeout = managed_client.startup_timeout;
|
||||
join_set.spawn(async move {
|
||||
let res = client_clone
|
||||
.list_tools(None, Some(LIST_TOOLS_TIMEOUT))
|
||||
.await;
|
||||
let res = client_clone.list_tools(None, Some(startup_timeout)).await;
|
||||
(server_name_cloned, res)
|
||||
});
|
||||
}
|
||||
@@ -251,8 +282,19 @@ async fn list_all_tools(
|
||||
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
|
||||
|
||||
while let Some(join_res) = join_set.join_next().await {
|
||||
let (server_name, list_result) = join_res?;
|
||||
let list_result = list_result?;
|
||||
let (server_name, list_result) = if let Ok(result) = join_res {
|
||||
result
|
||||
} else {
|
||||
warn!("Task panic when listing tools for MCP server: {join_res:#?}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let list_result = if let Ok(result) = list_result {
|
||||
result
|
||||
} else {
|
||||
warn!("Failed to list tools for MCP server '{server_name}': {list_result:#?}");
|
||||
continue;
|
||||
};
|
||||
|
||||
for tool in list_result.tools {
|
||||
let tool_info = ToolInfo {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! JSON-Lines tooling. Each record has the following schema:
|
||||
//!
|
||||
//! ````text
|
||||
//! {"session_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
|
||||
//! {"conversation_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
|
||||
//! ````
|
||||
//!
|
||||
//! To minimise the chance of interleaved writes when multiple processes are
|
||||
@@ -22,14 +22,15 @@ use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_types::HistoryPersistence;
|
||||
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[cfg(unix)]
|
||||
@@ -54,10 +55,14 @@ fn history_filepath(config: &Config) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
/// Append a `text` entry associated with `session_id` to the history file. Uses
|
||||
/// Append a `text` entry associated with `conversation_id` to the history file. Uses
|
||||
/// advisory file locking to ensure that concurrent writes do not interleave,
|
||||
/// which entails a small amount of blocking I/O internally.
|
||||
pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config) -> Result<()> {
|
||||
pub(crate) async fn append_entry(
|
||||
text: &str,
|
||||
conversation_id: &ConversationId,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
match config.history.persistence {
|
||||
HistoryPersistence::SaveAll => {
|
||||
// Save everything: proceed.
|
||||
@@ -84,7 +89,7 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
|
||||
|
||||
// Construct the JSON line first so we can write it in a single syscall.
|
||||
let entry = HistoryEntry {
|
||||
session_id: session_id.to_string(),
|
||||
session_id: conversation_id.to_string(),
|
||||
ts,
|
||||
text: text.to_string(),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ pub(crate) mod policy;
|
||||
pub mod recorder;
|
||||
|
||||
pub use recorder::RolloutRecorder;
|
||||
pub use recorder::RolloutRecorderParams;
|
||||
pub use recorder::SessionMeta;
|
||||
pub use recorder::SessionStateSnapshot;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Error as IoError;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -17,7 +19,6 @@ use tokio::sync::mpsc::{self};
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use super::list::ConversationsPage;
|
||||
@@ -26,13 +27,14 @@ use super::list::get_conversations;
|
||||
use super::policy::is_persisted_response_item;
|
||||
use crate::config::Config;
|
||||
use crate::conversation_manager::InitialHistory;
|
||||
use crate::conversation_manager::ResumedHistory;
|
||||
use crate::git_info::GitInfo;
|
||||
use crate::git_info::collect_git_info;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct SessionMeta {
|
||||
pub id: Uuid,
|
||||
pub id: ConversationId,
|
||||
pub timestamp: String,
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
@@ -55,7 +57,7 @@ pub struct SavedSession {
|
||||
pub items: Vec<ResponseItem>,
|
||||
#[serde(default)]
|
||||
pub state: SessionStateSnapshot,
|
||||
pub session_id: Uuid,
|
||||
pub session_id: ConversationId,
|
||||
}
|
||||
|
||||
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
|
||||
@@ -72,12 +74,36 @@ pub struct RolloutRecorder {
|
||||
tx: Sender<RolloutCmd>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RolloutRecorderParams {
|
||||
Create {
|
||||
conversation_id: ConversationId,
|
||||
instructions: Option<String>,
|
||||
},
|
||||
Resume {
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
enum RolloutCmd {
|
||||
AddItems(Vec<ResponseItem>),
|
||||
UpdateState(SessionStateSnapshot),
|
||||
Shutdown { ack: oneshot::Sender<()> },
|
||||
}
|
||||
|
||||
impl RolloutRecorderParams {
|
||||
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
|
||||
Self::Create {
|
||||
conversation_id,
|
||||
instructions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resume(path: PathBuf) -> Self {
|
||||
Self::Resume { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutRecorder {
|
||||
#[allow(dead_code)]
|
||||
/// List conversations (rollout files) under the provided Codex home directory.
|
||||
@@ -92,24 +118,43 @@ impl RolloutRecorder {
|
||||
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
|
||||
/// cannot be created or the rollout file cannot be opened we return the
|
||||
/// error so the caller can decide whether to disable persistence.
|
||||
pub async fn new(
|
||||
config: &Config,
|
||||
uuid: Uuid,
|
||||
instructions: Option<String>,
|
||||
) -> std::io::Result<Self> {
|
||||
let LogFileInfo {
|
||||
file,
|
||||
session_id,
|
||||
timestamp,
|
||||
} = create_log_file(config, uuid)?;
|
||||
pub async fn new(config: &Config, params: RolloutRecorderParams) -> std::io::Result<Self> {
|
||||
let (file, meta) = match params {
|
||||
RolloutRecorderParams::Create {
|
||||
conversation_id,
|
||||
instructions,
|
||||
} => {
|
||||
let LogFileInfo {
|
||||
file,
|
||||
conversation_id: session_id,
|
||||
timestamp,
|
||||
} = create_log_file(config, conversation_id)?;
|
||||
|
||||
let timestamp_format: &[FormatItem] = format_description!(
|
||||
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
|
||||
);
|
||||
let timestamp = timestamp
|
||||
.to_offset(time::UtcOffset::UTC)
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
let timestamp_format: &[FormatItem] = format_description!(
|
||||
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
|
||||
);
|
||||
let timestamp = timestamp
|
||||
.to_offset(time::UtcOffset::UTC)
|
||||
.format(timestamp_format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
(
|
||||
tokio::fs::File::from_std(file),
|
||||
Some(SessionMeta {
|
||||
timestamp,
|
||||
id: session_id,
|
||||
instructions,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RolloutRecorderParams::Resume { path } => (
|
||||
tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await?,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
// Clone the cwd for the spawned task to collect git info asynchronously
|
||||
let cwd = config.cwd.clone();
|
||||
@@ -122,16 +167,7 @@ impl RolloutRecorder {
|
||||
// Spawn a Tokio task that owns the file handle and performs async
|
||||
// writes. Using `tokio::fs::File` keeps everything on the async I/O
|
||||
// driver instead of blocking the runtime.
|
||||
tokio::task::spawn(rollout_writer(
|
||||
tokio::fs::File::from_std(file),
|
||||
rx,
|
||||
Some(SessionMeta {
|
||||
timestamp,
|
||||
id: session_id,
|
||||
instructions,
|
||||
}),
|
||||
cwd,
|
||||
));
|
||||
tokio::task::spawn(rollout_writer(file, rx, meta, cwd));
|
||||
|
||||
Ok(Self { tx })
|
||||
}
|
||||
@@ -164,13 +200,28 @@ impl RolloutRecorder {
|
||||
|
||||
pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
|
||||
info!("Resuming rollout from {path:?}");
|
||||
tracing::error!("Resuming rollout from {path:?}");
|
||||
let text = tokio::fs::read_to_string(path).await?;
|
||||
let mut lines = text.lines();
|
||||
let _ = lines
|
||||
let first_line = lines
|
||||
.next()
|
||||
.ok_or_else(|| IoError::other("empty session file"))?;
|
||||
let mut items = Vec::new();
|
||||
let conversation_id = match serde_json::from_str::<SessionMeta>(first_line) {
|
||||
Ok(rollout_session_meta) => {
|
||||
tracing::error!(
|
||||
"Parsed conversation ID from rollout file: {:?}",
|
||||
rollout_session_meta.id
|
||||
);
|
||||
Some(rollout_session_meta.id)
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(IoError::other(format!(
|
||||
"failed to parse first line of rollout file as SessionMeta: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
@@ -198,12 +249,24 @@ impl RolloutRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
info!("Resumed rollout successfully from {path:?}");
|
||||
tracing::error!(
|
||||
"Resumed rollout with {} items, conversation ID: {:?}",
|
||||
items.len(),
|
||||
conversation_id
|
||||
);
|
||||
let conversation_id = conversation_id
|
||||
.ok_or_else(|| IoError::other("failed to parse conversation ID from rollout file"))?;
|
||||
|
||||
if items.is_empty() {
|
||||
Ok(InitialHistory::New)
|
||||
} else {
|
||||
Ok(InitialHistory::Resumed(items))
|
||||
return Ok(InitialHistory::New);
|
||||
}
|
||||
|
||||
info!("Resumed rollout successfully from {path:?}");
|
||||
Ok(InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id,
|
||||
history: items,
|
||||
rollout_path: path.to_path_buf(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> std::io::Result<()> {
|
||||
@@ -227,13 +290,16 @@ struct LogFileInfo {
|
||||
file: File,
|
||||
|
||||
/// Session ID (also embedded in filename).
|
||||
session_id: Uuid,
|
||||
conversation_id: ConversationId,
|
||||
|
||||
/// Timestamp for the start of the session.
|
||||
timestamp: OffsetDateTime,
|
||||
}
|
||||
|
||||
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
|
||||
fn create_log_file(
|
||||
config: &Config,
|
||||
conversation_id: ConversationId,
|
||||
) -> std::io::Result<LogFileInfo> {
|
||||
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
|
||||
let timestamp = OffsetDateTime::now_local()
|
||||
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
@@ -252,7 +318,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
.format(format)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
let filename = format!("rollout-{date_str}-{session_id}.jsonl");
|
||||
let filename = format!("rollout-{date_str}-{conversation_id}.jsonl");
|
||||
|
||||
let path = dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
@@ -262,7 +328,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
|
||||
Ok(LogFileInfo {
|
||||
file,
|
||||
session_id,
|
||||
conversation_id,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
@@ -328,7 +394,7 @@ impl JsonlWriter {
|
||||
async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> {
|
||||
let mut json = serde_json::to_string(item)?;
|
||||
json.push('\n');
|
||||
let _ = self.file.write_all(json.as_bytes()).await;
|
||||
self.file.write_all(json.as_bytes()).await?;
|
||||
self.file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ pub struct ZshShell {
|
||||
zshrc_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct BashShell {
|
||||
shell_path: String,
|
||||
bashrc_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct PowerShellConfig {
|
||||
exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
|
||||
@@ -18,6 +24,7 @@ pub struct PowerShellConfig {
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub enum Shell {
|
||||
Zsh(ZshShell),
|
||||
Bash(BashShell),
|
||||
PowerShell(PowerShellConfig),
|
||||
Unknown,
|
||||
}
|
||||
@@ -26,22 +33,10 @@ impl Shell {
|
||||
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
|
||||
match self {
|
||||
Shell::Zsh(zsh) => {
|
||||
if !std::path::Path::new(&zsh.zshrc_path).exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = vec![zsh.shell_path.clone()];
|
||||
result.push("-lc".to_string());
|
||||
|
||||
let joined = strip_bash_lc(&command)
|
||||
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok());
|
||||
|
||||
if let Some(joined) = joined {
|
||||
result.push(format!("source {} && ({joined})", zsh.zshrc_path));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
Some(result)
|
||||
format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path)
|
||||
}
|
||||
Shell::Bash(bash) => {
|
||||
format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path)
|
||||
}
|
||||
Shell::PowerShell(ps) => {
|
||||
// If model generated a bash command, prefer a detected bash fallback
|
||||
@@ -97,12 +92,32 @@ impl Shell {
|
||||
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
Shell::PowerShell(ps) => Some(ps.exe.clone()),
|
||||
Shell::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_shell_invocation_with_rc(
|
||||
command: &Vec<String>,
|
||||
shell_path: &str,
|
||||
rc_path: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let joined = strip_bash_lc(command)
|
||||
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?;
|
||||
|
||||
let rc_command = if std::path::Path::new(rc_path).exists() {
|
||||
format!("source {rc_path} && ({joined})")
|
||||
} else {
|
||||
joined
|
||||
};
|
||||
|
||||
Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
|
||||
}
|
||||
|
||||
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
|
||||
match command.as_slice() {
|
||||
// exactly three items
|
||||
@@ -116,44 +131,43 @@ fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn default_user_shell() -> Shell {
|
||||
use tokio::process::Command;
|
||||
use whoami;
|
||||
#[cfg(unix)]
|
||||
fn detect_default_user_shell() -> Shell {
|
||||
use libc::getpwuid;
|
||||
use libc::getuid;
|
||||
use std::ffi::CStr;
|
||||
|
||||
let user = whoami::username();
|
||||
let home = format!("/Users/{user}");
|
||||
let output = Command::new("dscl")
|
||||
.args([".", "-read", &home, "UserShell"])
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
match output {
|
||||
Some(o) => {
|
||||
if !o.status.success() {
|
||||
return Shell::Unknown;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(shell_path) = line.strip_prefix("UserShell: ")
|
||||
&& shell_path.ends_with("/zsh")
|
||||
{
|
||||
return Shell::Zsh(ZshShell {
|
||||
shell_path: shell_path.to_string(),
|
||||
zshrc_path: format!("{home}/.zshrc"),
|
||||
});
|
||||
}
|
||||
unsafe {
|
||||
let uid = getuid();
|
||||
let pw = getpwuid(uid);
|
||||
|
||||
if !pw.is_null() {
|
||||
let shell_path = CStr::from_ptr((*pw).pw_shell)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let home_path = CStr::from_ptr((*pw).pw_dir).to_string_lossy().into_owned();
|
||||
|
||||
if shell_path.ends_with("/zsh") {
|
||||
return Shell::Zsh(ZshShell {
|
||||
shell_path,
|
||||
zshrc_path: format!("{home_path}/.zshrc"),
|
||||
});
|
||||
}
|
||||
|
||||
Shell::Unknown
|
||||
if shell_path.ends_with("/bash") {
|
||||
return Shell::Bash(BashShell {
|
||||
shell_path,
|
||||
bashrc_path: format!("{home_path}/.bashrc"),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => Shell::Unknown,
|
||||
}
|
||||
Shell::Unknown
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
#[cfg(unix)]
|
||||
pub async fn default_user_shell() -> Shell {
|
||||
Shell::Unknown
|
||||
detect_default_user_shell()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -196,8 +210,13 @@ pub async fn default_user_shell() -> Shell {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "windows"), not(unix)))]
|
||||
pub async fn default_user_shell() -> Shell {
|
||||
Shell::Unknown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
@@ -230,9 +249,127 @@ mod tests {
|
||||
zshrc_path: "/does/not/exist/.zshrc".to_string(),
|
||||
});
|
||||
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
|
||||
assert_eq!(actual_cmd, None);
|
||||
assert_eq!(
|
||||
actual_cmd,
|
||||
Some(vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"myecho".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_with_profile_bashrc_not_exists() {
|
||||
let shell = Shell::Bash(BashShell {
|
||||
shell_path: "/bin/bash".to_string(),
|
||||
bashrc_path: "/does/not/exist/.bashrc".to_string(),
|
||||
});
|
||||
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
|
||||
assert_eq!(
|
||||
actual_cmd,
|
||||
Some(vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"myecho".to_string()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_with_profile_bash_escaping_and_execution() {
|
||||
let shell_path = "/bin/bash";
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
vec!["myecho"],
|
||||
vec![shell_path, "-lc", "source BASHRC_PATH && (myecho)"],
|
||||
Some("It works!\n"),
|
||||
),
|
||||
(
|
||||
vec!["bash", "-lc", "echo 'single' \"double\""],
|
||||
vec![
|
||||
shell_path,
|
||||
"-lc",
|
||||
"source BASHRC_PATH && (echo 'single' \"double\")",
|
||||
],
|
||||
Some("single double\n"),
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_cmd, expected_output) in cases {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::exec::process_exec_tool_call;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
let temp_home = tempfile::tempdir().unwrap();
|
||||
let bashrc_path = temp_home.path().join(".bashrc");
|
||||
std::fs::write(
|
||||
&bashrc_path,
|
||||
r#"
|
||||
set -x
|
||||
function myecho {
|
||||
echo 'It works!'
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shell = Shell::Bash(BashShell {
|
||||
shell_path: shell_path.to_string(),
|
||||
bashrc_path: bashrc_path.to_str().unwrap().to_string(),
|
||||
});
|
||||
|
||||
let actual_cmd = shell
|
||||
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
|
||||
let expected_cmd = expected_cmd
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap())
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(actual_cmd, Some(expected_cmd));
|
||||
|
||||
let output = process_exec_tool_call(
|
||||
ExecParams {
|
||||
command: actual_cmd.unwrap(),
|
||||
cwd: PathBuf::from(temp_home.path()),
|
||||
timeout_ms: None,
|
||||
env: HashMap::from([(
|
||||
"HOME".to_string(),
|
||||
temp_home.path().to_str().unwrap().to_string(),
|
||||
)]),
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
|
||||
if let Some(expected) = expected_output {
|
||||
assert_eq!(
|
||||
output.stdout.text, expected,
|
||||
"input: {input:?} output: {output:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_with_profile_escaping_and_execution() {
|
||||
let shell_path = "/bin/zsh";
|
||||
|
||||
@@ -11,11 +11,11 @@ use codex_core::ReasoningItemContent;
|
||||
use codex_core::ResponseItem;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -76,7 +76,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
|
||||
provider,
|
||||
effort,
|
||||
summary,
|
||||
Uuid::new_v4(),
|
||||
ConversationId::new(),
|
||||
);
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
|
||||
@@ -8,10 +8,10 @@ use codex_core::ResponseEvent;
|
||||
use codex_core::ResponseItem;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use futures::StreamExt;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -69,7 +69,7 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
|
||||
provider,
|
||||
effort,
|
||||
summary,
|
||||
Uuid::new_v4(),
|
||||
ConversationId::new(),
|
||||
);
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
|
||||
@@ -388,8 +388,7 @@ async fn integration_creates_and_checks_session_file() {
|
||||
"No message found in session file containing the marker"
|
||||
);
|
||||
|
||||
// Second run: resume should create a NEW session file that contains both old and new history.
|
||||
let orig_len = content.lines().count();
|
||||
// Second run: resume should update the existing file.
|
||||
let marker2 = format!("integration-resume-{}", Uuid::new_v4());
|
||||
let prompt2 = format!("echo {marker2}");
|
||||
// Cross‑platform safe resume override. On Windows, backslashes in a TOML string must be escaped
|
||||
@@ -449,8 +448,8 @@ async fn integration_creates_and_checks_session_file() {
|
||||
}
|
||||
|
||||
let resumed_path = resumed_path.expect("No resumed session file found containing the marker2");
|
||||
// Resume should have written to a new file, not the original one.
|
||||
assert_ne!(
|
||||
// Resume should write to the existing log file.
|
||||
assert_eq!(
|
||||
resumed_path, path,
|
||||
"resume should create a new session file"
|
||||
);
|
||||
@@ -464,14 +463,6 @@ async fn integration_creates_and_checks_session_file() {
|
||||
resumed_content.contains(&marker2),
|
||||
"resumed file missing resumed marker"
|
||||
);
|
||||
|
||||
// Original file should remain unchanged.
|
||||
let content_after = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(
|
||||
content_after.lines().count(),
|
||||
orig_len,
|
||||
"original rollout file should not change on resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test to verify git info is collected and recorded in session files.
|
||||
|
||||
@@ -15,6 +15,7 @@ use core_test_support::wait_for_event;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -122,11 +123,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let session_path = tmpdir.path().join("resume-session.jsonl");
|
||||
let mut f = std::fs::File::create(&session_path).unwrap();
|
||||
// First line: meta (content not used by reader other than non-empty)
|
||||
writeln!(
|
||||
f,
|
||||
"{}",
|
||||
serde_json::json!({"meta":"test","instructions":"be nice"})
|
||||
json!({"meta":"test","instructions":"be nice", "id": Uuid::new_v4(), "timestamp": "2024-01-01T00:00:00Z"})
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
.clone()
|
||||
.expect("expected initial messages for resumed session");
|
||||
let initial_json = serde_json::to_value(&initial_msgs).unwrap();
|
||||
let expected_initial_json = serde_json::json!([
|
||||
let expected_initial_json = json!([
|
||||
{ "type": "user_message", "message": "resumed user message", "kind": "plain" },
|
||||
{ "type": "agent_message", "message": "resumed assistant message" }
|
||||
]);
|
||||
@@ -221,22 +221,19 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let expected_input = serde_json::json!([
|
||||
let expected_input = json!([
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "resumed user message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "output_text", "text": "resumed assistant message" }]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": "hello" }]
|
||||
}
|
||||
@@ -245,7 +242,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_session_id_and_model_headers_in_request() {
|
||||
async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
@@ -302,12 +299,12 @@ async fn includes_session_id_and_model_headers_in_request() {
|
||||
|
||||
// get request from the server
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_session_id = request.headers.get("session_id").unwrap();
|
||||
let request_conversation_id = request.headers.get("conversation_id").unwrap();
|
||||
let request_authorization = request.headers.get("authorization").unwrap();
|
||||
let request_originator = request.headers.get("originator").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request_session_id.to_str().unwrap(),
|
||||
request_conversation_id.to_str().unwrap(),
|
||||
conversation_id.to_string()
|
||||
);
|
||||
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
|
||||
@@ -480,14 +477,14 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
|
||||
// get request from the server
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_session_id = request.headers.get("session_id").unwrap();
|
||||
let request_conversation_id = request.headers.get("conversation_id").unwrap();
|
||||
let request_authorization = request.headers.get("authorization").unwrap();
|
||||
let request_originator = request.headers.get("originator").unwrap();
|
||||
let request_chatgpt_account_id = request.headers.get("chatgpt-account-id").unwrap();
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request_session_id.to_str().unwrap(),
|
||||
request_conversation_id.to_str().unwrap(),
|
||||
conversation_id.to_string()
|
||||
);
|
||||
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
|
||||
@@ -496,7 +493,6 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
"Bearer Access Token"
|
||||
);
|
||||
assert_eq!(request_chatgpt_account_id.to_str().unwrap(), "account_id");
|
||||
assert!(!request_body["store"].as_bool().unwrap());
|
||||
assert!(request_body["stream"].as_bool().unwrap());
|
||||
assert_eq!(
|
||||
request_body["include"][0].as_str().unwrap(),
|
||||
@@ -578,14 +574,6 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// verify request body flags
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
assert!(
|
||||
!request_body["store"].as_bool().unwrap(),
|
||||
"store should be false for ChatGPT auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -662,14 +650,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// verify request body flags
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let request_body = request.body_json::<serde_json::Value>().unwrap();
|
||||
assert!(
|
||||
request_body["store"].as_bool().unwrap(),
|
||||
"store should be true for API key auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -987,34 +967,29 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)");
|
||||
|
||||
// Replace full-array compare with tail-only raw JSON compare using a single hard-coded value.
|
||||
let r3_tail_expected = serde_json::json!([
|
||||
let r3_tail_expected = json!([
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U1"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "assistant",
|
||||
"content": [{"type":"output_text","text":"Hey there!\n"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U2"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "assistant",
|
||||
"content": [{"type":"output_text","text":"Hey there!\n"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": null,
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U3"}]
|
||||
}
|
||||
|
||||
@@ -289,20 +289,17 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let expected_env_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text } ]
|
||||
});
|
||||
let expected_ui_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_ui_text } ]
|
||||
});
|
||||
|
||||
let expected_user_message_1 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 1" } ]
|
||||
});
|
||||
@@ -314,7 +311,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let expected_user_message_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
@@ -424,7 +420,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
// as the prefix of the second request, ensuring cache hit potential.
|
||||
let expected_user_message_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
@@ -438,7 +433,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
|
||||
</environment_context>"#;
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
@@ -543,7 +537,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
// as the prefix of the second request.
|
||||
let expected_user_message_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"id": serde_json::Value::Null,
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::num_format::format_with_separators;
|
||||
use owo_colors::OwoColorize;
|
||||
use owo_colors::Style;
|
||||
use shlex::try_join;
|
||||
@@ -189,8 +190,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
return CodexStatus::InitiateShutdown;
|
||||
}
|
||||
EventMsg::TokenCount(token_usage) => {
|
||||
ts_println!(self, "tokens used: {}", token_usage.blended_total());
|
||||
EventMsg::TokenCount(ev) => {
|
||||
if let Some(usage_info) = ev.info {
|
||||
ts_println!(
|
||||
self,
|
||||
"tokens used: {}",
|
||||
format_with_separators(usage_info.total_token_usage.blended_total())
|
||||
);
|
||||
}
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
if !self.answer_started {
|
||||
@@ -511,7 +518,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
EventMsg::SessionConfigured(session_configured_event) => {
|
||||
let SessionConfiguredEvent {
|
||||
session_id,
|
||||
session_id: conversation_id,
|
||||
model,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
@@ -522,7 +529,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
self,
|
||||
"{} {}",
|
||||
"codex session".style(self.magenta).style(self.bold),
|
||||
session_id.to_string().style(self.dimmed)
|
||||
conversation_id.to_string().style(self.dimmed)
|
||||
);
|
||||
|
||||
ts_println!(self, "model: {}", model);
|
||||
|
||||
@@ -149,7 +149,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
include_plan_tool: None,
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: oss.then_some(true),
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::io::{self};
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::pkce::generate_pkce;
|
||||
@@ -85,7 +90,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let pkce = generate_pkce();
|
||||
let state = opts.force_state.clone().unwrap_or_else(generate_state);
|
||||
|
||||
let server = Server::http(format!("127.0.0.1:{}", opts.port)).map_err(io::Error::other)?;
|
||||
let server = bind_server(opts.port)?;
|
||||
let actual_port = match server.server_addr().to_ip() {
|
||||
Some(addr) => addr.port(),
|
||||
None => {
|
||||
@@ -145,19 +150,24 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let response =
|
||||
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
|
||||
|
||||
let is_login_complete = matches!(response, HandledRequest::ResponseAndExit(_));
|
||||
match response {
|
||||
HandledRequest::Response(r) | HandledRequest::ResponseAndExit(r) => {
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(r)).await;
|
||||
let exit_result = match response {
|
||||
HandledRequest::Response(response) => {
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
|
||||
None
|
||||
}
|
||||
HandledRequest::ResponseAndExit { response, result } => {
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
|
||||
Some(result)
|
||||
}
|
||||
HandledRequest::RedirectWithHeader(header) => {
|
||||
let redirect = Response::empty(302).with_header(header);
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if is_login_complete {
|
||||
break Ok(());
|
||||
if let Some(result) = exit_result {
|
||||
break result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +191,10 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
enum HandledRequest {
|
||||
Response(Response<Cursor<Vec<u8>>>),
|
||||
RedirectWithHeader(Header),
|
||||
ResponseAndExit(Response<Cursor<Vec<u8>>>),
|
||||
ResponseAndExit {
|
||||
response: Response<Cursor<Vec<u8>>>,
|
||||
result: io::Result<()>,
|
||||
},
|
||||
}
|
||||
|
||||
async fn process_request(
|
||||
@@ -276,8 +289,18 @@ async fn process_request(
|
||||
) {
|
||||
resp.add_header(h);
|
||||
}
|
||||
HandledRequest::ResponseAndExit(resp)
|
||||
HandledRequest::ResponseAndExit {
|
||||
response: resp,
|
||||
result: Ok(()),
|
||||
}
|
||||
}
|
||||
"/cancel" => HandledRequest::ResponseAndExit {
|
||||
response: Response::from_string("Login cancelled"),
|
||||
result: Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"Login cancelled",
|
||||
)),
|
||||
},
|
||||
_ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
|
||||
}
|
||||
}
|
||||
@@ -316,6 +339,68 @@ fn generate_state() -> String {
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn send_cancel_request(port: u16) -> io::Result<()> {
|
||||
let addr: SocketAddr = format!("127.0.0.1:{port}")
|
||||
.parse()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||
|
||||
stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
|
||||
stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
|
||||
stream.write_all(b"Connection: close\r\n\r\n")?;
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
let _ = stream.read(&mut buf);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_server(port: u16) -> io::Result<Server> {
|
||||
let bind_address = format!("127.0.0.1:{port}");
|
||||
let mut cancel_attempted = false;
|
||||
let mut attempts = 0;
|
||||
const MAX_ATTEMPTS: u32 = 10;
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(200);
|
||||
|
||||
loop {
|
||||
match Server::http(&bind_address) {
|
||||
Ok(server) => return Ok(server),
|
||||
Err(err) => {
|
||||
attempts += 1;
|
||||
let is_addr_in_use = err
|
||||
.downcast_ref::<io::Error>()
|
||||
.map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
|
||||
.unwrap_or(false);
|
||||
|
||||
// If the address is in use, there is probably another instance of the login server
|
||||
// running. Attempt to cancel it and retry.
|
||||
if is_addr_in_use {
|
||||
if !cancel_attempted {
|
||||
cancel_attempted = true;
|
||||
if let Err(cancel_err) = send_cancel_request(port) {
|
||||
eprintln!("Failed to cancel previous login server: {cancel_err}");
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(RETRY_DELAY);
|
||||
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
format!("Port {bind_address} is already in use"),
|
||||
));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(io::Error::other(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExchangedTokens {
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use codex_login::ServerOptions;
|
||||
@@ -177,3 +179,67 @@ async fn creates_missing_codex_home_dir() {
|
||||
"auth.json should be created even if parent dir was missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn cancels_previous_login_server_when_port_is_in_use() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let (issuer_addr, _issuer_handle) = start_mock_issuer();
|
||||
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
|
||||
|
||||
let first_tmp = tempdir().unwrap();
|
||||
let first_codex_home = first_tmp.path().to_path_buf();
|
||||
|
||||
let first_opts = ServerOptions {
|
||||
codex_home: first_codex_home,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer: issuer.clone(),
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some("cancel_state".to_string()),
|
||||
originator: "test_originator".to_string(),
|
||||
};
|
||||
|
||||
let first_server = run_login_server(first_opts).unwrap();
|
||||
let login_port = first_server.actual_port;
|
||||
let first_server_task = tokio::spawn(async move { first_server.block_until_done().await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let second_tmp = tempdir().unwrap();
|
||||
let second_codex_home = second_tmp.path().to_path_buf();
|
||||
|
||||
let second_opts = ServerOptions {
|
||||
codex_home: second_codex_home,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer,
|
||||
port: login_port,
|
||||
open_browser: false,
|
||||
force_state: Some("cancel_state_2".to_string()),
|
||||
originator: "test_originator".to_string(),
|
||||
};
|
||||
|
||||
let second_server = run_login_server(second_opts).unwrap();
|
||||
assert_eq!(second_server.actual_port, login_port);
|
||||
|
||||
let cancel_result = first_server_task
|
||||
.await
|
||||
.expect("first login server task panicked")
|
||||
.expect_err("login server should report cancellation");
|
||||
assert_eq!(cancel_result.kind(), io::ErrorKind::Interrupted);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let cancel_url = format!("http://127.0.0.1:{login_port}/cancel");
|
||||
let resp = client.get(cancel_url).send().await.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
second_server
|
||||
.block_until_done()
|
||||
.await
|
||||
.expect_err("second login server should report cancellation");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::json_to_toml::json_to_toml;
|
||||
@@ -14,11 +9,13 @@ use codex_core::ConversationManager;
|
||||
use codex_core::Cursor as RolloutCursor;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::SessionMeta;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::load_config_as_toml;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::get_platform_sandbox;
|
||||
@@ -48,6 +45,7 @@ use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse;
|
||||
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
|
||||
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
|
||||
use codex_protocol::mcp_protocol::ExecOneOffCommandParams;
|
||||
use codex_protocol::mcp_protocol::GetUserAgentResponse;
|
||||
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
|
||||
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
|
||||
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
|
||||
@@ -68,8 +66,16 @@ use codex_protocol::mcp_protocol::SendUserTurnParams;
|
||||
use codex_protocol::mcp_protocol::SendUserTurnResponse;
|
||||
use codex_protocol::mcp_protocol::ServerNotification;
|
||||
use codex_protocol::mcp_protocol::UserSavedConfig;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InputMessageKind;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
use mcp_types::JSONRPCErrorError;
|
||||
use mcp_types::RequestId;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
@@ -99,7 +105,7 @@ pub(crate) struct CodexMessageProcessor {
|
||||
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
|
||||
active_login: Arc<Mutex<Option<ActiveLogin>>>,
|
||||
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
|
||||
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
|
||||
pending_interrupts: Arc<Mutex<HashMap<ConversationId, Vec<RequestId>>>>,
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
@@ -169,6 +175,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::GetUserSavedConfig { request_id } => {
|
||||
self.get_user_saved_config(request_id).await;
|
||||
}
|
||||
ClientRequest::GetUserAgent { request_id } => {
|
||||
self.get_user_agent(request_id).await;
|
||||
}
|
||||
ClientRequest::ExecOneOffCommand { request_id, params } => {
|
||||
self.exec_one_off_command(request_id, params).await;
|
||||
}
|
||||
@@ -384,6 +393,12 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn get_user_agent(&self, request_id: RequestId) {
|
||||
let user_agent = get_codex_user_agent(Some(&self.config.responses_originator_header));
|
||||
let response = GetUserAgentResponse { user_agent };
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn get_user_saved_config(&self, request_id: RequestId) {
|
||||
let toml_value = match load_config_as_toml(&self.config.codex_home) {
|
||||
Ok(val) => val,
|
||||
@@ -511,7 +526,7 @@ impl CodexMessageProcessor {
|
||||
..
|
||||
} = conversation_id;
|
||||
let response = NewConversationResponse {
|
||||
conversation_id: ConversationId(conversation_id),
|
||||
conversation_id,
|
||||
model: session_configured.model,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
@@ -559,16 +574,11 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
// Build summaries
|
||||
let mut items: Vec<ConversationSummary> = Vec::new();
|
||||
for it in page.items.into_iter() {
|
||||
let (timestamp, preview) = extract_ts_and_preview(&it.head);
|
||||
items.push(ConversationSummary {
|
||||
path: it.path,
|
||||
preview,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(|it| extract_conversation_summary(it.path, &it.head))
|
||||
.collect();
|
||||
|
||||
// Encode next_cursor as a plain string
|
||||
let next_cursor = match page.next_cursor {
|
||||
@@ -622,19 +632,29 @@ impl CodexMessageProcessor {
|
||||
session_configured,
|
||||
..
|
||||
}) => {
|
||||
let event = codex_core::protocol::Event {
|
||||
let event = Event {
|
||||
id: "".to_string(),
|
||||
msg: codex_core::protocol::EventMsg::SessionConfigured(
|
||||
session_configured.clone(),
|
||||
),
|
||||
msg: EventMsg::SessionConfigured(session_configured.clone()),
|
||||
};
|
||||
self.outgoing.send_event_as_notification(&event, None).await;
|
||||
let initial_messages = session_configured.initial_messages.map(|msgs| {
|
||||
msgs.into_iter()
|
||||
.filter(|event| {
|
||||
// Don't send non-plain user messages (like user instructions
|
||||
// or environment context) back so they don't get rendered.
|
||||
if let EventMsg::UserMessage(user_message) = event {
|
||||
return matches!(user_message.kind, Some(InputMessageKind::Plain));
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Reply with conversation id + model and initial messages (when present)
|
||||
let response = codex_protocol::mcp_protocol::ResumeConversationResponse {
|
||||
conversation_id: ConversationId(conversation_id),
|
||||
conversation_id,
|
||||
model: session_configured.model.clone(),
|
||||
initial_messages: session_configured.initial_messages.clone(),
|
||||
initial_messages,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
@@ -656,7 +676,7 @@ impl CodexMessageProcessor {
|
||||
} = params;
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id.0)
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -704,7 +724,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id.0)
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -750,7 +770,7 @@ impl CodexMessageProcessor {
|
||||
let InterruptConversationParams { conversation_id } = params;
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id.0)
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -765,7 +785,7 @@ impl CodexMessageProcessor {
|
||||
// Record the pending interrupt so we can reply when TurnAborted arrives.
|
||||
{
|
||||
let mut map = self.pending_interrupts.lock().await;
|
||||
map.entry(conversation_id.0).or_default().push(request_id);
|
||||
map.entry(conversation_id).or_default().push(request_id);
|
||||
}
|
||||
|
||||
// Submit the interrupt; we'll respond upon TurnAborted.
|
||||
@@ -780,12 +800,12 @@ impl CodexMessageProcessor {
|
||||
let AddConversationListenerParams { conversation_id } = params;
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id.0)
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("conversation not found: {}", conversation_id.0),
|
||||
message: format!("conversation not found: {conversation_id}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -822,11 +842,11 @@ impl CodexMessageProcessor {
|
||||
let mut params = match serde_json::to_value(event.clone()) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
Ok(_) => {
|
||||
tracing::error!("event did not serialize to an object");
|
||||
error!("event did not serialize to an object");
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("failed to serialize event: {err}");
|
||||
error!("failed to serialize event: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -898,7 +918,7 @@ async fn apply_bespoke_event_handling(
|
||||
conversation_id: ConversationId,
|
||||
conversation: Arc<CodexConversation>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
|
||||
pending_interrupts: Arc<Mutex<HashMap<ConversationId, Vec<RequestId>>>>,
|
||||
) {
|
||||
let Event { id: event_id, msg } = event;
|
||||
match msg {
|
||||
@@ -951,7 +971,7 @@ async fn apply_bespoke_event_handling(
|
||||
EventMsg::TurnAborted(turn_aborted_event) => {
|
||||
let pending = {
|
||||
let mut map = pending_interrupts.lock().await;
|
||||
map.remove(&conversation_id.0).unwrap_or_default()
|
||||
map.remove(&conversation_id).unwrap_or_default()
|
||||
};
|
||||
if !pending.is_empty() {
|
||||
let response = InterruptConversationResponse {
|
||||
@@ -994,7 +1014,6 @@ fn derive_config_from_params(
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
tools_web_search_request: None,
|
||||
};
|
||||
@@ -1010,7 +1029,7 @@ fn derive_config_from_params(
|
||||
|
||||
async fn on_patch_approval_response(
|
||||
event_id: String,
|
||||
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||||
receiver: oneshot::Receiver<mcp_types::Result>,
|
||||
codex: Arc<CodexConversation>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
@@ -1052,14 +1071,14 @@ async fn on_patch_approval_response(
|
||||
|
||||
async fn on_exec_approval_response(
|
||||
event_id: String,
|
||||
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
|
||||
receiver: oneshot::Receiver<mcp_types::Result>,
|
||||
conversation: Arc<CodexConversation>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::error!("request failed: {err:?}");
|
||||
error!("request failed: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1086,37 +1105,99 @@ async fn on_exec_approval_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_ts_and_preview(head: &[serde_json::Value]) -> (Option<String>, String) {
|
||||
let ts = head
|
||||
.first()
|
||||
.and_then(|v| v.get("timestamp"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let preview = find_first_user_text(head).unwrap_or_default();
|
||||
(ts, preview)
|
||||
fn extract_conversation_summary(
|
||||
path: PathBuf,
|
||||
head: &[serde_json::Value],
|
||||
) -> Option<ConversationSummary> {
|
||||
let session_meta = match head.first() {
|
||||
Some(first_line) => match serde_json::from_value::<SessionMeta>(first_line.clone()) {
|
||||
Ok(session_meta) => session_meta,
|
||||
Err(..) => return None,
|
||||
},
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let preview = head
|
||||
.iter()
|
||||
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
content.into_iter().find_map(|content| match content {
|
||||
ContentItem::InputText { text } => {
|
||||
match InputMessageKind::from(("user", &text)) {
|
||||
InputMessageKind::Plain => Some(text),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
let preview = match preview.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
||||
None => preview.as_str(),
|
||||
};
|
||||
|
||||
let timestamp = if session_meta.timestamp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_meta.timestamp.clone())
|
||||
};
|
||||
|
||||
Some(ConversationSummary {
|
||||
conversation_id: session_meta.id,
|
||||
timestamp,
|
||||
path,
|
||||
preview: preview.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
for v in head.iter() {
|
||||
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if t != "message" {
|
||||
continue;
|
||||
}
|
||||
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
for c in arr.iter() {
|
||||
if let (Some("input_text"), Some(txt)) =
|
||||
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
|
||||
&& let Some(s) = txt.as_str()
|
||||
&& matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain)
|
||||
{
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_conversation_summary_prefers_plain_user_messages() {
|
||||
let conversation_id =
|
||||
ConversationId(Uuid::parse_str("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap());
|
||||
let timestamp = Some("2025-09-05T16:53:11.850Z".to_string());
|
||||
let path = PathBuf::from("rollout.jsonl");
|
||||
|
||||
let head = vec![
|
||||
json!({
|
||||
"id": conversation_id.0,
|
||||
"timestamp": timestamp,
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<user_instructions>\n<AGENTS.md contents>\n</user_instructions>".to_string(),
|
||||
}],
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": format!("<prior context> {USER_MESSAGE_BEGIN}Count to 5"),
|
||||
}],
|
||||
}),
|
||||
];
|
||||
|
||||
let summary = extract_conversation_summary(path.clone(), &head).expect("summary");
|
||||
|
||||
assert_eq!(summary.conversation_id, conversation_id);
|
||||
assert_eq!(
|
||||
summary.timestamp,
|
||||
Some("2025-09-05T16:53:11.850Z".to_string())
|
||||
);
|
||||
assert_eq!(summary.path, path);
|
||||
assert_eq!(summary.preview, "Count to 5");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -162,7 +162,6 @@ impl CodexToolCallParam {
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
tools_web_search_request: None,
|
||||
};
|
||||
@@ -182,8 +181,8 @@ impl CodexToolCallParam {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexToolCallReplyParam {
|
||||
/// The *session id* for this conversation.
|
||||
pub session_id: String,
|
||||
/// The conversation id for this Codex session.
|
||||
pub conversation_id: String,
|
||||
|
||||
/// The *next user prompt* to continue the Codex conversation.
|
||||
pub prompt: String,
|
||||
@@ -214,7 +213,8 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
|
||||
input_schema: tool_input_schema,
|
||||
output_schema: None,
|
||||
description: Some(
|
||||
"Continue a Codex session by providing the session id and prompt.".to_string(),
|
||||
"Continue a Codex conversation by providing the conversation id and prompt."
|
||||
.to_string(),
|
||||
),
|
||||
annotations: None,
|
||||
}
|
||||
@@ -309,21 +309,21 @@ mod tests {
|
||||
let tool = create_tool_for_codex_tool_call_reply_param();
|
||||
let tool_json = serde_json::to_value(&tool).expect("tool serializes");
|
||||
let expected_tool_json = serde_json::json!({
|
||||
"description": "Continue a Codex session by providing the session id and prompt.",
|
||||
"description": "Continue a Codex conversation by providing the conversation id and prompt.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"conversationId": {
|
||||
"description": "The conversation id for this Codex session.",
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"description": "The *next user prompt* to continue the Codex conversation.",
|
||||
"type": "string"
|
||||
},
|
||||
"sessionId": {
|
||||
"description": "The *session id* for this conversation.",
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"conversationId",
|
||||
"prompt",
|
||||
"sessionId",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::exec_approval::handle_exec_approval_request;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::OutgoingNotificationMeta;
|
||||
use crate::patch_approval::handle_patch_approval_request;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
@@ -18,18 +22,13 @@ use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::TextContent;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::exec_approval::handle_exec_approval_request;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use crate::outgoing_message::OutgoingNotificationMeta;
|
||||
use crate::patch_approval::handle_patch_approval_request;
|
||||
|
||||
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
|
||||
|
||||
@@ -43,7 +42,7 @@ pub async fn run_codex_tool_session(
|
||||
config: CodexConfig,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
|
||||
) {
|
||||
let NewConversation {
|
||||
conversation_id,
|
||||
@@ -119,13 +118,13 @@ pub async fn run_codex_tool_session_reply(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
request_id: RequestId,
|
||||
prompt: String,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
session_id: Uuid,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
|
||||
conversation_id: ConversationId,
|
||||
) {
|
||||
running_requests_id_to_codex_uuid
|
||||
.lock()
|
||||
.await
|
||||
.insert(request_id.clone(), session_id);
|
||||
.insert(request_id.clone(), conversation_id);
|
||||
if let Err(e) = conversation
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text { text: prompt }],
|
||||
@@ -154,7 +153,7 @@ async fn run_codex_tool_session_inner(
|
||||
codex: Arc<CodexConversation>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
request_id: RequestId,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
|
||||
) {
|
||||
let request_id_str = match &request_id {
|
||||
RequestId::String(s) => s.clone(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_protocol::mcp_protocol::ClientRequest;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
@@ -41,7 +42,7 @@ pub(crate) struct MessageProcessor {
|
||||
initialized: bool,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
|
||||
}
|
||||
|
||||
impl MessageProcessor {
|
||||
@@ -436,7 +437,10 @@ impl MessageProcessor {
|
||||
tracing::info!("tools/call -> params: {:?}", arguments);
|
||||
|
||||
// parse arguments
|
||||
let CodexToolCallReplyParam { session_id, prompt } = match arguments {
|
||||
let CodexToolCallReplyParam {
|
||||
conversation_id,
|
||||
prompt,
|
||||
} = match arguments {
|
||||
Some(json_val) => match serde_json::from_value::<CodexToolCallReplyParam>(json_val) {
|
||||
Ok(params) => params,
|
||||
Err(e) => {
|
||||
@@ -457,12 +461,12 @@ impl MessageProcessor {
|
||||
},
|
||||
None => {
|
||||
tracing::error!(
|
||||
"Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required."
|
||||
"Missing arguments for codex-reply tool-call; the `conversation_id` and `prompt` fields are required."
|
||||
);
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required.".to_owned(),
|
||||
text: "Missing arguments for codex-reply tool-call; the `conversation_id` and `prompt` fields are required.".to_owned(),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
@@ -473,14 +477,14 @@ impl MessageProcessor {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let session_id = match Uuid::parse_str(&session_id) {
|
||||
Ok(id) => id,
|
||||
let conversation_id = match Uuid::parse_str(&conversation_id) {
|
||||
Ok(id) => ConversationId::from(id),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse session_id: {e}");
|
||||
tracing::error!("Failed to parse conversation_id: {e}");
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Failed to parse session_id: {e}"),
|
||||
text: format!("Failed to parse conversation_id: {e}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
@@ -496,14 +500,18 @@ impl MessageProcessor {
|
||||
let outgoing = self.outgoing.clone();
|
||||
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
|
||||
|
||||
let codex = match self.conversation_manager.get_conversation(session_id).await {
|
||||
let codex = match self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Session not found for session_id: {session_id}");
|
||||
tracing::warn!("Session not found for conversation_id: {conversation_id}");
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
r#type: "text".to_owned(),
|
||||
text: format!("Session not found for session_id: {session_id}"),
|
||||
text: format!("Session not found for conversation_id: {conversation_id}"),
|
||||
annotations: None,
|
||||
})],
|
||||
is_error: Some(true),
|
||||
@@ -528,7 +536,7 @@ impl MessageProcessor {
|
||||
request_id,
|
||||
prompt,
|
||||
running_requests_id_to_codex_uuid,
|
||||
session_id,
|
||||
conversation_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -564,24 +572,28 @@ impl MessageProcessor {
|
||||
RequestId::Integer(i) => i.to_string(),
|
||||
};
|
||||
|
||||
// Obtain the session_id while holding the first lock, then release.
|
||||
let session_id = {
|
||||
// Obtain the conversation id while holding the first lock, then release.
|
||||
let conversation_id = {
|
||||
let map_guard = self.running_requests_id_to_codex_uuid.lock().await;
|
||||
match map_guard.get(&request_id) {
|
||||
Some(id) => *id, // Uuid is Copy
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
tracing::warn!("Session not found for request_id: {}", request_id_string);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
tracing::info!("session_id: {session_id}");
|
||||
tracing::info!("conversation_id: {conversation_id}");
|
||||
|
||||
// Obtain the Codex conversation from the server.
|
||||
let codex_arc = match self.conversation_manager.get_conversation(session_id).await {
|
||||
let codex_arc = match self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
tracing::warn!("Session not found for session_id: {session_id}");
|
||||
tracing::warn!("Session not found for conversation_id: {conversation_id}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -258,6 +258,7 @@ pub(crate) struct OutgoingError {
|
||||
mod tests {
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -270,10 +271,11 @@ mod tests {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: Uuid::new_v4(),
|
||||
session_id: conversation_id,
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
@@ -302,8 +304,9 @@ mod tests {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: Uuid::new_v4(),
|
||||
session_id: conversation_id,
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
|
||||
@@ -247,6 +247,11 @@ impl McpProcess {
|
||||
self.send_request("getUserSavedConfig", None).await
|
||||
}
|
||||
|
||||
/// Send a `getUserAgent` JSON-RPC request.
|
||||
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("getUserAgent", None).await
|
||||
}
|
||||
|
||||
/// Send a `listConversations` JSON-RPC request.
|
||||
pub async fn send_list_conversations_request(
|
||||
&mut self,
|
||||
|
||||
@@ -51,7 +51,6 @@ model_reasoning_effort = "high"
|
||||
model_reasoning_summary = "detailed"
|
||||
model_verbosity = "medium"
|
||||
model_provider = "openai"
|
||||
disable_response_storage = false
|
||||
chatgpt_base_url = "https://api.chatgpt.com"
|
||||
"#,
|
||||
)
|
||||
@@ -111,7 +110,6 @@ async fn get_config_toml_parses_all_fields() {
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
model_provider: Some("openai".into()),
|
||||
disable_response_storage: Some(false),
|
||||
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
|
||||
},
|
||||
)]),
|
||||
|
||||
@@ -142,7 +142,7 @@ async fn test_list_and_resume_conversations() {
|
||||
} = to_response::<ResumeConversationResponse>(resume_resp)
|
||||
.expect("deserialize resumeConversation response");
|
||||
// conversation id should be a valid UUID
|
||||
let _ = uuid::Uuid::from_bytes(conversation_id.0.into_bytes());
|
||||
let _: uuid::Uuid = conversation_id.into();
|
||||
}
|
||||
|
||||
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
|
||||
@@ -157,7 +157,7 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
|
||||
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
|
||||
let mut lines = Vec::new();
|
||||
// Meta line with timestamp
|
||||
lines.push(json!({"timestamp": meta_rfc3339}).to_string());
|
||||
lines.push(json!({"timestamp": meta_rfc3339, "id": uuid}).to_string());
|
||||
// Minimal user message entry as a persisted response item
|
||||
lines.push(
|
||||
json!({
|
||||
|
||||
@@ -8,3 +8,4 @@ mod interrupt;
|
||||
mod list_resume;
|
||||
mod login;
|
||||
mod send_message;
|
||||
mod user_agent;
|
||||
|
||||
45
codex-rs/mcp-server/tests/suite/user_agent.rs
Normal file
45
codex-rs/mcp-server/tests/suite/user_agent.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use codex_core::default_client::DEFAULT_ORIGINATOR;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_protocol::mcp_protocol::GetUserAgentResponse;
|
||||
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);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_user_agent_returns_current_codex_user_agent() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|err| panic!("create tempdir: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("initialize timeout")
|
||||
.expect("initialize request");
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_user_agent_request()
|
||||
.await
|
||||
.expect("send getUserAgent");
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getUserAgent timeout")
|
||||
.expect("getUserAgent response");
|
||||
|
||||
let received: GetUserAgentResponse =
|
||||
to_response(response).expect("deserialize getUserAgent response");
|
||||
let expected = GetUserAgentResponse {
|
||||
user_agent: get_codex_user_agent(Some(DEFAULT_ORIGINATOR)),
|
||||
};
|
||||
|
||||
assert_eq!(received, expected);
|
||||
}
|
||||
@@ -9,4 +9,4 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ts-rs = { version = "11", features = ["serde-json-impl"] }
|
||||
ts-rs = { version = "11", features = ["serde-json-impl", "no-serde-warnings"] }
|
||||
|
||||
@@ -20,34 +20,25 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?;
|
||||
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::GetUserSavedConfigResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?;
|
||||
|
||||
generate_index_ts(out_dir)?;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
icu_decimal = "2.0.0"
|
||||
icu_locale_core = "2.0.0"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
mime_guess = "2.0.5"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -20,8 +22,9 @@ serde_json = "1"
|
||||
serde_with = { version = "3.14.0", features = ["macros", "base64"] }
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
sys-locale = "0.3.2"
|
||||
tracing = "0.1.41"
|
||||
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] }
|
||||
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl", "no-serde-warnings"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||
pub struct CustomPrompt {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod custom_prompts;
|
||||
pub mod mcp_protocol;
|
||||
pub mod message_history;
|
||||
pub mod models;
|
||||
pub mod num_format;
|
||||
pub mod parse_command;
|
||||
pub mod plan_tool;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -19,16 +19,40 @@ use strum_macros::Display;
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)]
|
||||
#[ts(type = "string")]
|
||||
pub struct ConversationId(pub Uuid);
|
||||
|
||||
impl ConversationId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConversationId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ConversationId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for ConversationId {
|
||||
fn from(value: Uuid) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConversationId> for Uuid {
|
||||
fn from(value: ConversationId) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
|
||||
#[ts(type = "string")]
|
||||
pub struct GitSha(pub String);
|
||||
@@ -119,6 +143,10 @@ pub enum ClientRequest {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
},
|
||||
GetUserAgent {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
},
|
||||
/// Execute a command (argv vector) under the server's sandbox.
|
||||
ExecOneOffCommand {
|
||||
#[serde(rename = "id")]
|
||||
@@ -177,7 +205,7 @@ pub struct NewConversationResponse {
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResumeConversationResponse {
|
||||
pub conversation_id: ConversationId,
|
||||
@@ -200,6 +228,7 @@ pub struct ListConversationsParams {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationSummary {
|
||||
pub conversation_id: ConversationId,
|
||||
pub path: PathBuf,
|
||||
pub preview: String,
|
||||
/// RFC3339 timestamp string for the session start, if available.
|
||||
@@ -321,6 +350,12 @@ pub struct GetAuthStatusResponse {
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetUserAgentResponse {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetUserSavedConfigResponse {
|
||||
@@ -371,7 +406,6 @@ pub struct Profile {
|
||||
/// [`ModelProviderInfo`] to use.
|
||||
pub model_provider: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
@@ -604,4 +638,10 @@ mod tests {
|
||||
serde_json::to_value(&request).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_id_default_is_not_zeroes() {
|
||||
let id = ConversationId::default();
|
||||
assert_ne!(id.0, Uuid::nil());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||
pub struct HistoryEntry {
|
||||
pub session_id: String,
|
||||
pub conversation_id: String,
|
||||
pub ts: u64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::ser::Serializer;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::protocol::InputItem;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseInputItem {
|
||||
Message {
|
||||
@@ -30,7 +31,7 @@ pub enum ResponseInputItem {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentItem {
|
||||
InputText { text: String },
|
||||
@@ -38,15 +39,17 @@ pub enum ContentItem {
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseItem {
|
||||
Message {
|
||||
#[serde(skip_serializing)]
|
||||
id: Option<String>,
|
||||
role: String,
|
||||
content: Vec<ContentItem>,
|
||||
},
|
||||
Reasoning {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
summary: Vec<ReasoningItemReasoningSummary>,
|
||||
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
|
||||
@@ -55,6 +58,7 @@ pub enum ResponseItem {
|
||||
},
|
||||
LocalShellCall {
|
||||
/// Set when using the chat completions API.
|
||||
#[serde(skip_serializing)]
|
||||
id: Option<String>,
|
||||
/// Set when using the Responses API.
|
||||
call_id: Option<String>,
|
||||
@@ -62,6 +66,7 @@ pub enum ResponseItem {
|
||||
action: LocalShellAction,
|
||||
},
|
||||
FunctionCall {
|
||||
#[serde(skip_serializing)]
|
||||
id: Option<String>,
|
||||
name: String,
|
||||
// The Responses API returns the function call arguments as a *string* that contains
|
||||
@@ -82,7 +87,7 @@ pub enum ResponseItem {
|
||||
output: FunctionCallOutputPayload,
|
||||
},
|
||||
CustomToolCall {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing)]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
@@ -104,7 +109,7 @@ pub enum ResponseItem {
|
||||
// "action": {"type":"search","query":"weather: San Francisco, CA"}
|
||||
// }
|
||||
WebSearchCall {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip_serializing)]
|
||||
id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
@@ -155,7 +160,7 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalShellStatus {
|
||||
Completed,
|
||||
@@ -163,13 +168,13 @@ pub enum LocalShellStatus {
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LocalShellAction {
|
||||
Exec(LocalShellExecAction),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
pub struct LocalShellExecAction {
|
||||
pub command: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
@@ -178,7 +183,7 @@ pub struct LocalShellExecAction {
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WebSearchAction {
|
||||
Search {
|
||||
@@ -188,13 +193,13 @@ pub enum WebSearchAction {
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningItemReasoningSummary {
|
||||
SummaryText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningItemContent {
|
||||
ReasoningText { text: String },
|
||||
@@ -238,7 +243,7 @@ impl From<Vec<InputItem>> for ResponseInputItem {
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
|
||||
/// or shell`, the `arguments` field should deserialize to this struct.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
pub struct ShellToolCallParams {
|
||||
pub command: Vec<String>,
|
||||
pub workdir: Option<String>,
|
||||
@@ -252,7 +257,7 @@ pub struct ShellToolCallParams {
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, TS)]
|
||||
pub struct FunctionCallOutputPayload {
|
||||
pub content: String,
|
||||
pub success: Option<bool>,
|
||||
|
||||
98
codex-rs/protocol/src/num_format.rs
Normal file
98
codex-rs/protocol/src/num_format.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use icu_decimal::DecimalFormatter;
|
||||
use icu_decimal::input::Decimal;
|
||||
use icu_decimal::options::DecimalFormatterOptions;
|
||||
use icu_locale_core::Locale;
|
||||
|
||||
fn make_local_formatter() -> Option<DecimalFormatter> {
|
||||
let loc: Locale = sys_locale::get_locale()?.parse().ok()?;
|
||||
DecimalFormatter::try_new(loc.into(), DecimalFormatterOptions::default()).ok()
|
||||
}
|
||||
|
||||
fn make_en_us_formatter() -> DecimalFormatter {
|
||||
#![allow(clippy::expect_used)]
|
||||
let loc: Locale = "en-US".parse().expect("en-US wasn't a valid locale");
|
||||
DecimalFormatter::try_new(loc.into(), DecimalFormatterOptions::default())
|
||||
.expect("en-US wasn't a valid locale")
|
||||
}
|
||||
|
||||
fn formatter() -> &'static DecimalFormatter {
|
||||
static FORMATTER: OnceLock<DecimalFormatter> = OnceLock::new();
|
||||
FORMATTER.get_or_init(|| make_local_formatter().unwrap_or_else(make_en_us_formatter))
|
||||
}
|
||||
|
||||
/// Format a u64 with locale-aware digit separators (e.g. "12345" -> "12,345"
|
||||
/// for en-US).
|
||||
pub fn format_with_separators(n: u64) -> String {
|
||||
formatter().format(&Decimal::from(n)).to_string()
|
||||
}
|
||||
|
||||
fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> String {
|
||||
if n < 1000 {
|
||||
return formatter.format(&Decimal::from(n)).to_string();
|
||||
}
|
||||
|
||||
// Format `n / scale` with the requested number of fractional digits.
|
||||
let format_scaled = |n: u64, scale: u64, frac_digits: u32| -> String {
|
||||
let value = n as f64 / scale as f64;
|
||||
let scaled: u64 = (value * 10f64.powi(frac_digits as i32)).round() as u64;
|
||||
let mut dec = Decimal::from(scaled);
|
||||
dec.multiply_pow10(-(frac_digits as i16));
|
||||
formatter.format(&dec).to_string()
|
||||
};
|
||||
|
||||
const UNITS: [(u64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")];
|
||||
let f = n as f64;
|
||||
for &(scale, suffix) in &UNITS {
|
||||
if (100.0 * f / scale as f64).round() < 1000.0 {
|
||||
return format!("{}{}", format_scaled(n, scale, 2), suffix);
|
||||
} else if (10.0 * f / scale as f64).round() < 1000.0 {
|
||||
return format!("{}{}", format_scaled(n, scale, 1), suffix);
|
||||
} else if (f / scale as f64).round() < 1000.0 {
|
||||
return format!("{}{}", format_scaled(n, scale, 0), suffix);
|
||||
}
|
||||
}
|
||||
|
||||
// Above 1000G, keep whole‑G precision.
|
||||
format!(
|
||||
"{}G",
|
||||
format_with_separators(((n as f64) / 1e9).round() as u64)
|
||||
)
|
||||
}
|
||||
|
||||
/// Format token counts to 3 significant figures, using base-10 SI suffixes.
|
||||
///
|
||||
/// Examples (en-US):
|
||||
/// - 999 -> "999"
|
||||
/// - 1200 -> "1.20K"
|
||||
/// - 123456789 -> "123M"
|
||||
pub fn format_si_suffix(n: u64) -> String {
|
||||
format_si_suffix_with_formatter(n, formatter())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kmg() {
|
||||
let formatter = make_en_us_formatter();
|
||||
let fmt = |n: u64| format_si_suffix_with_formatter(n, &formatter);
|
||||
assert_eq!(fmt(0), "0");
|
||||
assert_eq!(fmt(999), "999");
|
||||
assert_eq!(fmt(1_000), "1.00K");
|
||||
assert_eq!(fmt(1_200), "1.20K");
|
||||
assert_eq!(fmt(10_000), "10.0K");
|
||||
assert_eq!(fmt(100_000), "100K");
|
||||
assert_eq!(fmt(999_500), "1.00M");
|
||||
assert_eq!(fmt(1_000_000), "1.00M");
|
||||
assert_eq!(fmt(1_234_000), "1.23M");
|
||||
assert_eq!(fmt(12_345_678), "12.3M");
|
||||
assert_eq!(fmt(999_950_000), "1.00G");
|
||||
assert_eq!(fmt(1_000_000_000), "1.00G");
|
||||
assert_eq!(fmt(1_234_000_000), "1.23G");
|
||||
// Above 1000G we keep whole‑G precision (no higher unit supported here).
|
||||
assert_eq!(fmt(1_234_000_000_000), "1,234G");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParsedCommand {
|
||||
Read {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
// Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
@@ -10,14 +11,14 @@ pub enum StepStatus {
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlanItemArg {
|
||||
pub step: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdatePlanArgs {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -10,7 +10,15 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::custom_prompts::CustomPrompt;
|
||||
use crate::mcp_protocol::ConversationId;
|
||||
use crate::message_history::HistoryEntry;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::num_format::format_with_separators;
|
||||
use crate::parse_command::ParsedCommand;
|
||||
use crate::plan_tool::UpdatePlanArgs;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::Tool as McpTool;
|
||||
use serde::Deserialize;
|
||||
@@ -18,14 +26,6 @@ use serde::Serialize;
|
||||
use serde_with::serde_as;
|
||||
use strum_macros::Display;
|
||||
use ts_rs::TS;
|
||||
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;
|
||||
|
||||
/// Open/close tags for special user-input blocks. Used across crates to avoid
|
||||
/// duplicated hardcoded strings.
|
||||
@@ -33,6 +33,7 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
|
||||
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
|
||||
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
|
||||
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
|
||||
|
||||
/// Submission Queue Entry - requests from user
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -404,7 +405,7 @@ pub struct Event {
|
||||
}
|
||||
|
||||
/// Response event from the agent
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Display)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum EventMsg {
|
||||
@@ -417,9 +418,9 @@ pub enum EventMsg {
|
||||
/// Agent has completed all actions
|
||||
TaskComplete(TaskCompleteEvent),
|
||||
|
||||
/// Token count event, sent periodically to report the number of tokens
|
||||
/// used in the current session.
|
||||
TokenCount(TokenUsage),
|
||||
/// Usage update for the current session, including totals and last turn.
|
||||
/// Optional means unknown — UIs should not display when `None`.
|
||||
TokenCount(TokenCountEvent),
|
||||
|
||||
/// Agent text output message
|
||||
AgentMessage(AgentMessageEvent),
|
||||
@@ -503,30 +504,72 @@ pub enum EventMsg {
|
||||
|
||||
// Individual event payload types matching each `EventMsg` variant.
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TaskCompleteEvent {
|
||||
pub last_agent_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TaskStartedEvent {
|
||||
pub model_context_window: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u64,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub cached_input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub reasoning_output_tokens: Option<u64>,
|
||||
pub reasoning_output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TokenUsageInfo {
|
||||
pub total_token_usage: TokenUsage,
|
||||
pub last_token_usage: TokenUsage,
|
||||
pub model_context_window: Option<u64>,
|
||||
}
|
||||
|
||||
impl TokenUsageInfo {
|
||||
pub fn new_or_append(
|
||||
info: &Option<TokenUsageInfo>,
|
||||
last: &Option<TokenUsage>,
|
||||
model_context_window: Option<u64>,
|
||||
) -> Option<Self> {
|
||||
if info.is_none() && last.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut info = match info {
|
||||
Some(info) => info.clone(),
|
||||
None => Self {
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
model_context_window,
|
||||
},
|
||||
};
|
||||
if let Some(last) = last {
|
||||
info.append_last_usage(last);
|
||||
}
|
||||
Some(info)
|
||||
}
|
||||
|
||||
pub fn append_last_usage(&mut self, last: &TokenUsage) {
|
||||
self.total_token_usage.add_assign(last);
|
||||
self.last_token_usage = last.clone();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TokenCountEvent {
|
||||
pub info: Option<TokenUsageInfo>,
|
||||
}
|
||||
|
||||
// Includes prompts, tools and space to call compact.
|
||||
const BASELINE_TOKENS: u64 = 12000;
|
||||
|
||||
@@ -536,7 +579,7 @@ impl TokenUsage {
|
||||
}
|
||||
|
||||
pub fn cached_input(&self) -> u64 {
|
||||
self.cached_input_tokens.unwrap_or(0)
|
||||
self.cached_input_tokens
|
||||
}
|
||||
|
||||
pub fn non_cached_input(&self) -> u64 {
|
||||
@@ -554,7 +597,7 @@ impl TokenUsage {
|
||||
/// This will be off for the current turn and pending function calls.
|
||||
pub fn tokens_in_context_window(&self) -> u64 {
|
||||
self.total_tokens
|
||||
.saturating_sub(self.reasoning_output_tokens.unwrap_or(0))
|
||||
.saturating_sub(self.reasoning_output_tokens)
|
||||
}
|
||||
|
||||
/// Estimate the remaining user-controllable percentage of the model's context window.
|
||||
@@ -579,6 +622,15 @@ impl TokenUsage {
|
||||
let remaining = effective_window.saturating_sub(used);
|
||||
((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8
|
||||
}
|
||||
|
||||
/// In-place element-wise sum of token counts.
|
||||
pub fn add_assign(&mut self, other: &TokenUsage) {
|
||||
self.input_tokens += other.input_tokens;
|
||||
self.cached_input_tokens += other.cached_input_tokens;
|
||||
self.output_tokens += other.output_tokens;
|
||||
self.reasoning_output_tokens += other.reasoning_output_tokens;
|
||||
self.total_tokens += other.total_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -595,31 +647,39 @@ impl From<TokenUsage> for FinalOutput {
|
||||
impl fmt::Display for FinalOutput {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let token_usage = &self.token_usage;
|
||||
|
||||
write!(
|
||||
f,
|
||||
"Token usage: total={} input={}{} output={}{}",
|
||||
token_usage.blended_total(),
|
||||
token_usage.non_cached_input(),
|
||||
format_with_separators(token_usage.blended_total()),
|
||||
format_with_separators(token_usage.non_cached_input()),
|
||||
if token_usage.cached_input() > 0 {
|
||||
format!(" (+ {} cached)", token_usage.cached_input())
|
||||
format!(
|
||||
" (+ {} cached)",
|
||||
format_with_separators(token_usage.cached_input())
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
token_usage.output_tokens,
|
||||
token_usage
|
||||
.reasoning_output_tokens
|
||||
.map(|r| format!(" (reasoning {r})"))
|
||||
.unwrap_or_default()
|
||||
format_with_separators(token_usage.output_tokens),
|
||||
if token_usage.reasoning_output_tokens > 0 {
|
||||
format!(
|
||||
" (reasoning {})",
|
||||
format_with_separators(token_usage.reasoning_output_tokens)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentMessageEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InputMessageKind {
|
||||
/// Plain user text (default)
|
||||
@@ -630,7 +690,7 @@ pub enum InputMessageKind {
|
||||
EnvironmentContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct UserMessageEvent {
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -660,35 +720,35 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentMessageDeltaEvent {
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentReasoningEvent {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentReasoningRawContentEvent {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentReasoningRawContentDeltaEvent {
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentReasoningSectionBreakEvent {}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct AgentReasoningDeltaEvent {
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct McpInvocation {
|
||||
/// Name of the MCP server as defined in the config.
|
||||
pub server: String,
|
||||
@@ -698,18 +758,19 @@ pub struct McpInvocation {
|
||||
pub arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct McpToolCallBeginEvent {
|
||||
/// Identifier so this can be paired with the McpToolCallEnd event.
|
||||
pub call_id: String,
|
||||
pub invocation: McpInvocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct McpToolCallEndEvent {
|
||||
/// Identifier for the corresponding McpToolCallBegin that finished.
|
||||
pub call_id: String,
|
||||
pub invocation: McpInvocation,
|
||||
#[ts(type = "string")]
|
||||
pub duration: Duration,
|
||||
/// Result of the tool call. Note this could be an error.
|
||||
pub result: Result<CallToolResult, String>,
|
||||
@@ -724,12 +785,12 @@ impl McpToolCallEndEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct WebSearchBeginEvent {
|
||||
pub call_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct WebSearchEndEvent {
|
||||
pub call_id: String,
|
||||
pub query: String,
|
||||
@@ -737,13 +798,13 @@ pub struct WebSearchEndEvent {
|
||||
|
||||
/// Response payload for `Op::GetHistory` containing the current session's
|
||||
/// in-memory transcript.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ConversationHistoryResponseEvent {
|
||||
pub conversation_id: Uuid,
|
||||
pub conversation_id: ConversationId,
|
||||
pub entries: Vec<ResponseItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ExecCommandBeginEvent {
|
||||
/// Identifier so this can be paired with the ExecCommandEnd event.
|
||||
pub call_id: String,
|
||||
@@ -754,7 +815,7 @@ pub struct ExecCommandBeginEvent {
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ExecCommandEndEvent {
|
||||
/// Identifier for the ExecCommandBegin that finished.
|
||||
pub call_id: String,
|
||||
@@ -768,12 +829,13 @@ pub struct ExecCommandEndEvent {
|
||||
/// The command's exit code.
|
||||
pub exit_code: i32,
|
||||
/// The duration of the command execution.
|
||||
#[ts(type = "string")]
|
||||
pub duration: Duration,
|
||||
/// Formatted output from the command, as seen by the model.
|
||||
pub formatted_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecOutputStream {
|
||||
Stdout,
|
||||
@@ -781,7 +843,7 @@ pub enum ExecOutputStream {
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
|
||||
pub struct ExecCommandOutputDeltaEvent {
|
||||
/// Identifier for the ExecCommandBegin that produced this chunk.
|
||||
pub call_id: String,
|
||||
@@ -789,10 +851,11 @@ pub struct ExecCommandOutputDeltaEvent {
|
||||
pub stream: ExecOutputStream,
|
||||
/// Raw bytes from the stream (may not be valid UTF-8).
|
||||
#[serde_as(as = "serde_with::base64::Base64")]
|
||||
#[ts(type = "string")]
|
||||
pub chunk: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
/// Identifier for the associated exec call, if available.
|
||||
pub call_id: String,
|
||||
@@ -805,7 +868,7 @@ pub struct ExecApprovalRequestEvent {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ApplyPatchApprovalRequestEvent {
|
||||
/// Responses API call id for the associated patch apply call, if available.
|
||||
pub call_id: String,
|
||||
@@ -818,17 +881,17 @@ pub struct ApplyPatchApprovalRequestEvent {
|
||||
pub grant_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct BackgroundEventEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct StreamErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct PatchApplyBeginEvent {
|
||||
/// Identifier so this can be paired with the PatchApplyEnd event.
|
||||
pub call_id: String,
|
||||
@@ -838,7 +901,7 @@ pub struct PatchApplyBeginEvent {
|
||||
pub changes: HashMap<PathBuf, FileChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct PatchApplyEndEvent {
|
||||
/// Identifier for the PatchApplyBegin that finished.
|
||||
pub call_id: String,
|
||||
@@ -850,12 +913,12 @@ pub struct PatchApplyEndEvent {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TurnDiffEvent {
|
||||
pub unified_diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct GetHistoryEntryResponseEvent {
|
||||
pub offset: usize,
|
||||
pub log_id: u64,
|
||||
@@ -865,22 +928,22 @@ pub struct GetHistoryEntryResponseEvent {
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListMcpTools`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct McpListToolsResponseEvent {
|
||||
/// Fully qualified tool name -> tool definition.
|
||||
pub tools: std::collections::HashMap<String, McpTool>,
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListCustomPrompts`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ListCustomPromptsResponseEvent {
|
||||
pub custom_prompts: Vec<CustomPrompt>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct SessionConfiguredEvent {
|
||||
/// Unique id for this session.
|
||||
pub session_id: Uuid,
|
||||
/// Name left as session_id instead of conversation_id for backwards compatibility.
|
||||
pub session_id: ConversationId,
|
||||
|
||||
/// Tell the client what model is being queried.
|
||||
pub model: String,
|
||||
@@ -934,7 +997,7 @@ pub enum FileChange {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct Chunk {
|
||||
/// 1-based line index of the first line in the original file
|
||||
pub orig_index: u32,
|
||||
@@ -942,7 +1005,7 @@ pub struct Chunk {
|
||||
pub inserted_lines: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct TurnAbortedEvent {
|
||||
pub reason: TurnAbortReason,
|
||||
}
|
||||
@@ -962,11 +1025,11 @@ mod tests {
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
fn serialize_event() {
|
||||
let session_id: Uuid = uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8");
|
||||
let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"));
|
||||
let event = Event {
|
||||
id: "1234".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id,
|
||||
session_id: conversation_id,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
|
||||
296
codex-rs/scripts/create_github_release
Executable file
296
codex-rs/scripts/create_github_release
Executable file
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
REPO = "openai/codex"
|
||||
BRANCH_REF = "heads/main"
|
||||
CARGO_TOML_PATH = "codex-rs/Cargo.toml"
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Publish a tagged Codex release.")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the version that would be used and exit before making changes.",
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--publish-alpha",
|
||||
action="store_true",
|
||||
help="Publish the next alpha release for the upcoming minor version.",
|
||||
)
|
||||
group.add_argument(
|
||||
"--publish-release",
|
||||
action="store_true",
|
||||
help="Publish the next stable release by bumping the minor version.",
|
||||
)
|
||||
return parser.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
version = determine_version(args)
|
||||
print(f"Publishing version {version}")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
print("Fetching branch head...")
|
||||
base_commit = get_branch_head()
|
||||
print(f"Base commit: {base_commit}")
|
||||
print("Fetching commit tree...")
|
||||
base_tree = get_commit_tree(base_commit)
|
||||
print(f"Base tree: {base_tree}")
|
||||
print("Fetching Cargo.toml...")
|
||||
current_contents = fetch_file_contents(base_commit)
|
||||
print("Updating version...")
|
||||
updated_contents = replace_version(current_contents, version)
|
||||
print("Creating blob...")
|
||||
blob_sha = create_blob(updated_contents)
|
||||
print(f"Blob SHA: {blob_sha}")
|
||||
print("Creating tree...")
|
||||
tree_sha = create_tree(base_tree, blob_sha)
|
||||
print(f"Tree SHA: {tree_sha}")
|
||||
print("Creating commit...")
|
||||
commit_sha = create_commit(version, tree_sha, base_commit)
|
||||
print(f"Commit SHA: {commit_sha}")
|
||||
print("Creating tag...")
|
||||
tag_sha = create_tag(version, commit_sha)
|
||||
print(f"Tag SHA: {tag_sha}")
|
||||
print("Creating tag ref...")
|
||||
create_tag_ref(version, tag_sha)
|
||||
print("Done.")
|
||||
except ReleaseError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = None) -> dict:
|
||||
print(f"Running gh api {method} {endpoint}")
|
||||
command = [
|
||||
"gh",
|
||||
"api",
|
||||
endpoint,
|
||||
"--method",
|
||||
method,
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
]
|
||||
json_payload = None
|
||||
if payload is not None:
|
||||
json_payload = json.dumps(payload)
|
||||
print(f"Payload: {json_payload}")
|
||||
command.extend(["-H", "Content-Type: application/json", "--input", "-"])
|
||||
result = subprocess.run(command, text=True, capture_output=True, input=json_payload)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip() or "gh api call failed"
|
||||
raise ReleaseError(message)
|
||||
try:
|
||||
return json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError as error:
|
||||
raise ReleaseError("Failed to parse response from gh api.") from error
|
||||
|
||||
|
||||
def get_branch_head() -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}")
|
||||
try:
|
||||
return response["object"]["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Unable to determine branch head.") from error
|
||||
|
||||
|
||||
def get_commit_tree(commit_sha: str) -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}")
|
||||
try:
|
||||
return response["tree"]["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Commit response missing tree SHA.") from error
|
||||
|
||||
|
||||
def fetch_file_contents(ref_sha: str) -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/contents/{CARGO_TOML_PATH}?ref={ref_sha}")
|
||||
try:
|
||||
encoded_content = response["content"].replace("\n", "")
|
||||
encoding = response.get("encoding", "")
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Failed to fetch Cargo.toml contents.") from error
|
||||
|
||||
if encoding != "base64":
|
||||
raise ReleaseError(f"Unexpected Cargo.toml encoding: {encoding}")
|
||||
|
||||
try:
|
||||
return base64.b64decode(encoded_content).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError) as error:
|
||||
raise ReleaseError("Failed to decode Cargo.toml contents.") from error
|
||||
|
||||
|
||||
def replace_version(contents: str, version: str) -> str:
|
||||
updated, matches = re.subn(
|
||||
r'^version = "[^"]+"', f'version = "{version}"', contents, count=1, flags=re.MULTILINE
|
||||
)
|
||||
if matches != 1:
|
||||
raise ReleaseError("Unable to update version in Cargo.toml.")
|
||||
return updated
|
||||
|
||||
|
||||
def create_blob(content: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/blobs",
|
||||
method="POST",
|
||||
payload={"content": content, "encoding": "utf-8"},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Blob creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tree(base_tree_sha: str, blob_sha: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/trees",
|
||||
method="POST",
|
||||
payload={
|
||||
"base_tree": base_tree_sha,
|
||||
"tree": [
|
||||
{
|
||||
"path": CARGO_TOML_PATH,
|
||||
"mode": "100644",
|
||||
"type": "blob",
|
||||
"sha": blob_sha,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Tree creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_commit(version: str, tree_sha: str, parent_sha: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/commits",
|
||||
method="POST",
|
||||
payload={
|
||||
"message": f"Release {version}",
|
||||
"tree": tree_sha,
|
||||
"parents": [parent_sha],
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Commit creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tag(version: str, commit_sha: str) -> str:
|
||||
tag_name = f"rust-v{version}"
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/tags",
|
||||
method="POST",
|
||||
payload={
|
||||
"tag": tag_name,
|
||||
"message": f"Release {version}",
|
||||
"object": commit_sha,
|
||||
"type": "commit",
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Tag creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tag_ref(version: str, tag_sha: str) -> None:
|
||||
tag_ref = f"refs/tags/rust-v{version}"
|
||||
run_gh_api(
|
||||
f"/repos/{REPO}/git/refs",
|
||||
method="POST",
|
||||
payload={"ref": tag_ref, "sha": tag_sha},
|
||||
)
|
||||
|
||||
|
||||
def determine_version(args: argparse.Namespace) -> str:
|
||||
latest_version = get_latest_release_version()
|
||||
major, minor, patch = parse_semver(latest_version)
|
||||
next_minor_version = format_version(major, minor + 1, patch)
|
||||
|
||||
if args.publish_release:
|
||||
return next_minor_version
|
||||
|
||||
alpha_prefix = f"{next_minor_version}-alpha."
|
||||
releases = list_releases()
|
||||
highest_alpha = 0
|
||||
found_alpha = False
|
||||
for release in releases:
|
||||
tag = release.get("tag_name", "")
|
||||
candidate = strip_tag_prefix(tag)
|
||||
if candidate and candidate.startswith(alpha_prefix):
|
||||
suffix = candidate[len(alpha_prefix) :]
|
||||
try:
|
||||
alpha_number = int(suffix)
|
||||
except ValueError:
|
||||
continue
|
||||
highest_alpha = max(highest_alpha, alpha_number)
|
||||
found_alpha = True
|
||||
|
||||
if found_alpha:
|
||||
return f"{alpha_prefix}{highest_alpha + 1}"
|
||||
return f"{alpha_prefix}1"
|
||||
|
||||
|
||||
def get_latest_release_version() -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/releases/latest")
|
||||
tag = response.get("tag_name")
|
||||
version = strip_tag_prefix(tag)
|
||||
if not version:
|
||||
raise ReleaseError("Latest release tag has unexpected format.")
|
||||
return version
|
||||
|
||||
|
||||
def list_releases() -> list[dict]:
|
||||
response = run_gh_api(f"/repos/{REPO}/releases?per_page=100")
|
||||
if not isinstance(response, list):
|
||||
raise ReleaseError("Unexpected response when listing releases.")
|
||||
return response
|
||||
|
||||
|
||||
def strip_tag_prefix(tag: str | None) -> str | None:
|
||||
if not tag:
|
||||
return None
|
||||
prefix = "rust-v"
|
||||
if not tag.startswith(prefix):
|
||||
return None
|
||||
return tag[len(prefix) :]
|
||||
|
||||
|
||||
def parse_semver(version: str) -> tuple[int, int, int]:
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ReleaseError(f"Unexpected version format: {version}")
|
||||
try:
|
||||
return int(parts[0]), int(parts[1]), int(parts[2])
|
||||
except ValueError as error:
|
||||
raise ReleaseError(f"Version components must be integers: {version}") from error
|
||||
|
||||
|
||||
def format_version(major: int, minor: int, patch: int) -> str:
|
||||
return f"{major}.{minor}.{patch}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# By default, this script uses a version based on the current date and time.
|
||||
# If you want to specify a version, pass it as the first argument. Example:
|
||||
#
|
||||
# ./scripts/create_github_release.sh 0.1.0-alpha.4
|
||||
#
|
||||
# The value will be used to update the `version` field in `Cargo.toml`.
|
||||
|
||||
# Change to the root of the Cargo workspace.
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
# Cancel if there are uncommitted changes.
|
||||
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
|
||||
echo "ERROR: You have uncommitted or untracked changes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail if in a detached HEAD state.
|
||||
CURRENT_BRANCH=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
|
||||
if [ -z "${CURRENT_BRANCH:-}" ]; then
|
||||
echo "ERROR: Could not determine the current branch (detached HEAD?)." >&2
|
||||
echo " Please run this script from a checked-out branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure we are on the 'main' branch before proceeding.
|
||||
if [ "${CURRENT_BRANCH}" != "main" ]; then
|
||||
echo "ERROR: Releases must be created from the 'main' branch (current: '${CURRENT_BRANCH}')." >&2
|
||||
echo " Please switch to 'main' and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the current local commit on 'main' is present on 'origin/main'.
|
||||
# This guarantees we only create releases from commits that are already on
|
||||
# the canonical repository (https://github.com/openai/codex).
|
||||
if ! git fetch --quiet origin main; then
|
||||
echo "ERROR: Failed to fetch 'origin/main'. Ensure the 'origin' remote is configured and reachable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor HEAD origin/main; then
|
||||
echo "ERROR: Your local 'main' HEAD commit is not present on 'origin/main'." >&2
|
||||
echo " Please push your commits first (git push origin main) or check out a commit on 'origin/main'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a new branch for the release and make a commit with the new version.
|
||||
if [ $# -ge 1 ]; then
|
||||
VERSION="$1"
|
||||
else
|
||||
VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)")
|
||||
fi
|
||||
TAG="rust-v$VERSION"
|
||||
git checkout -b "$TAG"
|
||||
perl -i -pe "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
|
||||
git add Cargo.toml
|
||||
git commit -m "Release $VERSION"
|
||||
git tag -a "$TAG" -m "Release $VERSION"
|
||||
git push origin "refs/tags/$TAG"
|
||||
|
||||
git checkout "$CURRENT_BRANCH"
|
||||
@@ -44,7 +44,7 @@ crossterm = { version = "0.28.1", features = [
|
||||
"event-stream",
|
||||
] }
|
||||
diffy = "0.4.2"
|
||||
image = { version = "^0.25.6", default-features = false, features = [
|
||||
image = { version = "^0.25.8", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
] }
|
||||
@@ -100,7 +100,7 @@ arboard = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
insta = "1.43.1"
|
||||
insta = "1.43.2"
|
||||
pretty_assertions = "1"
|
||||
rand = "0.9"
|
||||
vt100 = "0.16.2"
|
||||
|
||||
@@ -183,15 +183,6 @@ impl App {
|
||||
},
|
||||
)?;
|
||||
}
|
||||
TuiEvent::AttachImage {
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
format_label,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.attach_image(path, width, height, format_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
|
||||
@@ -4,23 +4,25 @@ use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::ConversationHistoryResponseEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
|
||||
/// Aggregates all backtrack-related state used by the App.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base conversation to fork from.
|
||||
pub(crate) base_id: Option<uuid::Uuid>,
|
||||
pub(crate) base_id: Option<ConversationId>,
|
||||
/// Current step count (Nth last user message).
|
||||
pub(crate) count: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending fork request: (base_id, drop_count, prefill).
|
||||
pub(crate) pending: Option<(uuid::Uuid, usize, String)>,
|
||||
pub(crate) pending: Option<(ConversationId, usize, String)>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -91,7 +93,7 @@ impl App {
|
||||
pub(crate) fn request_backtrack(
|
||||
&mut self,
|
||||
prefill: String,
|
||||
base_id: uuid::Uuid,
|
||||
base_id: ConversationId,
|
||||
drop_last_messages: usize,
|
||||
) {
|
||||
self.backtrack.pending = Some((base_id, drop_last_messages, prefill));
|
||||
@@ -135,7 +137,7 @@ impl App {
|
||||
fn prime_backtrack(&mut self) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.count = 0;
|
||||
self.backtrack.base_id = self.chat_widget.session_id();
|
||||
self.backtrack.base_id = self.chat_widget.conversation_id();
|
||||
self.chat_widget.show_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
@@ -151,7 +153,7 @@ impl App {
|
||||
/// When overlay is already open, begin preview mode and select latest user message.
|
||||
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.base_id = self.chat_widget.session_id();
|
||||
self.backtrack.base_id = self.chat_widget.conversation_id();
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
let sel = self.compute_backtrack_selection(tui, 1);
|
||||
self.apply_backtrack_selection(sel);
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(crate) fn highlight_range_for_nth_last_user(
|
||||
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
|
||||
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
|
||||
let before = &lines[0..header_idx];
|
||||
crate::insert_history::word_wrap_lines(before, width).len()
|
||||
crate::wrapping::word_wrap_lines(before, width as usize).len()
|
||||
}
|
||||
|
||||
/// Find the header index for the Nth last user message in the transcript.
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(crate) trait BottomPaneView {
|
||||
|
||||
/// Handle Ctrl-C while this view is active.
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
CancellationEvent::Ignored
|
||||
CancellationEvent::NotHandled
|
||||
}
|
||||
|
||||
/// Return the desired height of the view.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::num_format::format_si_suffix;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -63,12 +64,6 @@ struct AttachedImage {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
struct TokenUsageInfo {
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
model_context_window: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -85,6 +80,7 @@ pub(crate) struct ChatComposer {
|
||||
has_focus: bool,
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
is_task_running: bool,
|
||||
// Non-bracketed paste burst tracker.
|
||||
paste_burst: PasteBurst,
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
@@ -125,6 +121,7 @@ impl ChatComposer {
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
is_task_running: false,
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
custom_prompts: Vec::new(),
|
||||
@@ -166,17 +163,8 @@ impl ChatComposer {
|
||||
/// Update the cached *context-left* percentage and refresh the placeholder
|
||||
/// text. The UI relies on the placeholder to convey the remaining
|
||||
/// context when the composer is empty.
|
||||
pub(crate) fn set_token_usage(
|
||||
&mut self,
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
model_context_window: Option<u64>,
|
||||
) {
|
||||
self.token_usage_info = Some(TokenUsageInfo {
|
||||
total_token_usage,
|
||||
last_token_usage,
|
||||
model_context_window,
|
||||
});
|
||||
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
|
||||
self.token_usage_info = token_info;
|
||||
}
|
||||
|
||||
/// Record the history metadata advertised by `SessionConfiguredEvent` so
|
||||
@@ -1220,6 +1208,10 @@ impl ChatComposer {
|
||||
self.has_focus = has_focus;
|
||||
}
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
}
|
||||
|
||||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||||
self.esc_backtrack_hint = show;
|
||||
}
|
||||
@@ -1244,11 +1236,16 @@ impl WidgetRef for ChatComposer {
|
||||
ActivePopup::None => {
|
||||
let bottom_line_rect = popup_rect;
|
||||
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
|
||||
let ctrl_c_followup = if self.is_task_running {
|
||||
" to interrupt"
|
||||
} else {
|
||||
" to quit"
|
||||
};
|
||||
vec![
|
||||
" ".into(),
|
||||
key_hint::ctrl('C'),
|
||||
" again".into(),
|
||||
" to quit".into(),
|
||||
ctrl_c_followup.into(),
|
||||
]
|
||||
} else {
|
||||
let newline_hint_key = if self.use_shift_enter_hint {
|
||||
@@ -1280,8 +1277,11 @@ impl WidgetRef for ChatComposer {
|
||||
let token_usage = &token_usage_info.total_token_usage;
|
||||
hint.push(" ".into());
|
||||
hint.push(
|
||||
Span::from(format!("{} tokens used", token_usage.blended_total()))
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
Span::from(format!(
|
||||
"{} tokens used",
|
||||
format_si_suffix(token_usage.blended_total())
|
||||
))
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
);
|
||||
let last_token_usage = &token_usage_info.last_token_usage;
|
||||
if let Some(context_window) = token_usage_info.model_context_window {
|
||||
@@ -1290,11 +1290,16 @@ impl WidgetRef for ChatComposer {
|
||||
} else {
|
||||
100
|
||||
};
|
||||
let context_style = if percent_remaining < 20 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
};
|
||||
hint.push(" ".into());
|
||||
hint.push(
|
||||
Span::from(format!("{percent_remaining}% context left"))
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
);
|
||||
hint.push(Span::styled(
|
||||
format!("{percent_remaining}% context left"),
|
||||
context_style,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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_core::protocol::TokenUsageInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -30,8 +30,8 @@ mod textarea;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
Ignored,
|
||||
Handled,
|
||||
NotHandled,
|
||||
}
|
||||
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
@@ -195,7 +195,15 @@ impl BottomPane {
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
let mut view = match self.active_view.take() {
|
||||
Some(view) => view,
|
||||
None => return CancellationEvent::Ignored,
|
||||
None => {
|
||||
return if self.composer_is_empty() {
|
||||
CancellationEvent::NotHandled
|
||||
} else {
|
||||
self.set_composer_text(String::new());
|
||||
self.show_ctrl_c_quit_hint();
|
||||
CancellationEvent::Handled
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let event = view.on_ctrl_c(self);
|
||||
@@ -208,7 +216,7 @@ impl BottomPane {
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
}
|
||||
CancellationEvent::Ignored => {
|
||||
CancellationEvent::NotHandled => {
|
||||
self.active_view = Some(view);
|
||||
}
|
||||
}
|
||||
@@ -267,6 +275,7 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
@@ -289,6 +298,7 @@ impl BottomPane {
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
self.composer.set_task_running(running);
|
||||
|
||||
if running {
|
||||
if self.status.is_none() {
|
||||
@@ -358,14 +368,8 @@ impl BottomPane {
|
||||
|
||||
/// Update the *context-window remaining* indicator in the composer. This
|
||||
/// is forwarded directly to the underlying `ChatComposer`.
|
||||
pub(crate) fn set_token_usage(
|
||||
&mut self,
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
model_context_window: Option<u64>,
|
||||
) {
|
||||
self.composer
|
||||
.set_token_usage(total_token_usage, last_token_usage, model_context_window);
|
||||
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
|
||||
self.composer.set_token_usage(token_info);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -510,7 +514,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -519,7 +523,7 @@ mod tests {
|
||||
pane.push_approval_request(exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
assert!(pane.ctrl_c_quit_hint_visible());
|
||||
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
|
||||
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
// live ring removed; related tests deleted.
|
||||
@@ -530,7 +534,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -561,7 +565,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -589,7 +593,7 @@ mod tests {
|
||||
|
||||
// Render and ensure the top row includes the Working header and a composer line below.
|
||||
// Give the animation thread a moment to tick.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let area = Rect::new(0, 0, 40, 6);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
@@ -629,7 +633,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -660,7 +664,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -711,7 +715,7 @@ mod tests {
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
|
||||
@@ -832,25 +832,10 @@ impl TextArea {
|
||||
None => true,
|
||||
};
|
||||
if needs_recalc {
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
for line in textwrap::wrap(
|
||||
let lines = crate::wrapping::wrap_ranges(
|
||||
&self.text,
|
||||
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
)
|
||||
.iter()
|
||||
{
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start =
|
||||
unsafe { slice.as_ptr().offset_from(self.text.as_ptr()) as usize };
|
||||
let end = start + slice.len();
|
||||
let trailing_spaces =
|
||||
self.text[end..].chars().take_while(|c| *c == ' ').count();
|
||||
lines.push(start..end + trailing_spaces + 1);
|
||||
}
|
||||
std::borrow::Cow::Owned(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
);
|
||||
*cache = Some(WrapCache { width, lines });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::UserMessageEvent;
|
||||
@@ -57,6 +58,7 @@ use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::CommandOutput;
|
||||
@@ -83,7 +85,7 @@ 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;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
@@ -108,8 +110,7 @@ pub(crate) struct ChatWidget {
|
||||
active_exec_cell: Option<ExecCell>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
total_token_usage: TokenUsage,
|
||||
last_token_usage: TokenUsage,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
// Stream lifecycle controller
|
||||
stream: StreamController,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
@@ -120,7 +121,7 @@ pub(crate) struct ChatWidget {
|
||||
reasoning_buffer: String,
|
||||
// Accumulates full reasoning content for transcript-only recording
|
||||
full_reasoning_buffer: String,
|
||||
session_id: Option<Uuid>,
|
||||
conversation_id: Option<ConversationId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
@@ -162,7 +163,7 @@ impl ChatWidget {
|
||||
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.conversation_id = Some(event.session_id);
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
if let Some(messages) = initial_messages {
|
||||
self.replay_initial_messages(messages);
|
||||
@@ -258,16 +259,10 @@ impl ChatWidget {
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
|
||||
fn on_token_count(&mut self, token_usage: TokenUsage) {
|
||||
self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
|
||||
self.last_token_usage = token_usage;
|
||||
self.bottom_pane.set_token_usage(
|
||||
self.total_token_usage.clone(),
|
||||
self.last_token_usage.clone(),
|
||||
self.config.model_context_window,
|
||||
);
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
self.bottom_pane.set_token_usage(info.clone());
|
||||
self.token_info = info;
|
||||
}
|
||||
|
||||
/// Finalize any active exec as failed, push an error message into history,
|
||||
/// and stop/clear running UI state.
|
||||
fn finalize_turn_with_error_message(&mut self, message: String) {
|
||||
@@ -658,15 +653,14 @@ impl ChatWidget {
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
),
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
token_info: None,
|
||||
stream: StreamController::new(config),
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
conversation_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: false,
|
||||
@@ -711,15 +705,14 @@ impl ChatWidget {
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
),
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
token_info: None,
|
||||
stream: StreamController::new(config),
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
conversation_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
@@ -745,6 +738,17 @@ impl ChatWidget {
|
||||
self.on_ctrl_c();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('v'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if let Ok((path, info)) = paste_image_to_temp_png() {
|
||||
self.attach_image(path, info.width, info.height, info.encoded_format.label());
|
||||
}
|
||||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
}
|
||||
@@ -1038,7 +1042,7 @@ impl ChatWidget {
|
||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||
EventMsg::TaskStarted(_) => self.on_task_started(),
|
||||
EventMsg::TaskComplete(TaskCompleteEvent { .. }) => self.on_task_complete(),
|
||||
EventMsg::TokenCount(token_usage) => self.on_token_count(token_usage),
|
||||
EventMsg::TokenCount(ev) => self.set_token_info(ev.info),
|
||||
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
|
||||
EventMsg::TurnAborted(ev) => match ev.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
@@ -1145,10 +1149,17 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
let default_usage;
|
||||
let usage_ref = if let Some(ti) = &self.token_info {
|
||||
&ti.total_token_usage
|
||||
} else {
|
||||
default_usage = TokenUsage::default();
|
||||
&default_usage
|
||||
};
|
||||
self.add_to_history(history_cell::new_status_output(
|
||||
&self.config,
|
||||
&self.total_token_usage,
|
||||
&self.session_id,
|
||||
usage_ref,
|
||||
&self.conversation_id,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1277,15 +1288,17 @@ impl ChatWidget {
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
fn on_ctrl_c(&mut self) {
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Ignored {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.submit_op(Op::Interrupt);
|
||||
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||||
self.submit_op(Op::Shutdown);
|
||||
} else {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
}
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
return;
|
||||
}
|
||||
|
||||
self.submit_op(Op::Shutdown);
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
@@ -1340,12 +1353,15 @@ impl ChatWidget {
|
||||
self.submit_user_message(text.into());
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||||
&self.total_token_usage
|
||||
pub(crate) fn token_usage(&self) -> TokenUsage {
|
||||
self.token_info
|
||||
.as_ref()
|
||||
.map(|ti| ti.total_token_usage.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(&self) -> Option<Uuid> {
|
||||
self.session_id
|
||||
pub(crate) fn conversation_id(&self) -> Option<ConversationId> {
|
||||
self.conversation_id
|
||||
}
|
||||
|
||||
/// Return a reference to the widget's current config (includes any
|
||||
@@ -1355,12 +1371,8 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn clear_token_usage(&mut self) {
|
||||
self.total_token_usage = TokenUsage::default();
|
||||
self.bottom_pane.set_token_usage(
|
||||
self.total_token_usage.clone(),
|
||||
self.last_token_usage.clone(),
|
||||
self.config.model_context_window,
|
||||
);
|
||||
self.token_info = None;
|
||||
self.bottom_pane.set_token_usage(None);
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
@@ -1393,34 +1405,6 @@ const EXAMPLE_PROMPTS: [&str; 6] = [
|
||||
"Improve documentation in @filename",
|
||||
];
|
||||
|
||||
fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenUsage {
|
||||
let cached_input_tokens = match (
|
||||
current_usage.cached_input_tokens,
|
||||
new_usage.cached_input_tokens,
|
||||
) {
|
||||
(Some(current), Some(new)) => Some(current + new),
|
||||
(Some(current), None) => Some(current),
|
||||
(None, Some(new)) => Some(new),
|
||||
(None, None) => None,
|
||||
};
|
||||
let reasoning_output_tokens = match (
|
||||
current_usage.reasoning_output_tokens,
|
||||
new_usage.reasoning_output_tokens,
|
||||
) {
|
||||
(Some(current), Some(new)) => Some(current + new),
|
||||
(Some(current), None) => Some(current),
|
||||
(None, Some(new)) => Some(new),
|
||||
(None, None) => None,
|
||||
};
|
||||
TokenUsage {
|
||||
input_tokens: current_usage.input_tokens + new_usage.input_tokens,
|
||||
cached_input_tokens,
|
||||
output_tokens: current_usage.output_tokens + new_usage.output_tokens,
|
||||
reasoning_output_tokens,
|
||||
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 648
|
||||
expression: visible_after
|
||||
---
|
||||
> I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
@@ -22,9 +23,9 @@ expression: visible_after
|
||||
|
||||
Main Causes
|
||||
|
||||
- Static linking style: Each bin (codex, codex-tui, codex-exec,
|
||||
codex-mcp-server, etc.) statically links its full dependency graph, so common
|
||||
code isn’t shared at runtime across executables.
|
||||
- Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp-
|
||||
server, etc.) statically links its full dependency graph, so common code isn’t
|
||||
shared at runtime across executables.
|
||||
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
|
||||
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
|
||||
and ollama you enable reqwest with json/stream, which still pulls a large
|
||||
@@ -39,9 +40,9 @@ expression: visible_after
|
||||
per bin.
|
||||
- Panic + backtraces: Default panic = unwind and backtrace support keep
|
||||
unwinding tables and symbols that add weight.
|
||||
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables
|
||||
openssl-sys with vendored, compiling OpenSSL into the binary—this adds
|
||||
multiple megabytes per executable.
|
||||
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-
|
||||
sys with vendored, compiling OpenSSL into the binary—this adds multiple
|
||||
megabytes per executable.
|
||||
|
||||
Build-Mode Notes
|
||||
|
||||
@@ -52,6 +53,6 @@ expression: visible_after
|
||||
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
|
||||
assertions—outputs are much larger than cargo build --release.
|
||||
|
||||
If you want, I can outline targeted trims (e.g., strip = "debuginfo",
|
||||
opt-level = "z", panic abort, tighter tokio/reqwest features) and estimate
|
||||
impact per binary.
|
||||
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-
|
||||
level = "z", panic abort, tighter tokio/reqwest features) and estimate impact
|
||||
per binary.
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -35,11 +36,10 @@ use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
codex_core::config::Config::load_from_base_config_with_overrides(
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
@@ -79,7 +79,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 2000;
|
||||
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
@@ -132,13 +132,15 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
fn resumed_initial_messages_render_history() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: Uuid::nil(),
|
||||
session_id: conversation_id,
|
||||
model: "test-model".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![
|
||||
EventMsg::UserMessage(codex_core::protocol::UserMessageEvent {
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "hello from user".to_string(),
|
||||
kind: Some(InputMessageKind::Plain),
|
||||
}),
|
||||
@@ -175,6 +177,10 @@ fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
target_os = "macos",
|
||||
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
||||
)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -185,7 +191,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
)));
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
@@ -208,7 +214,7 @@ fn make_chatwidget_manual() -> (
|
||||
let cfg = test_config();
|
||||
let bottom = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -221,18 +227,17 @@ fn make_chatwidget_manual() -> (
|
||||
active_exec_cell: None,
|
||||
config: cfg.clone(),
|
||||
initial_user_message: None,
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
token_info: None,
|
||||
stream: StreamController::new(cfg),
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
conversation_id: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: std::collections::VecDeque::new(),
|
||||
queued_user_messages: VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
@@ -368,11 +373,10 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
|
||||
// Build the full command vec and parse it using core's parser,
|
||||
// then convert to protocol variants for the event payload.
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
|
||||
let parsed_cmd: Vec<codex_protocol::parse_command::ParsedCommand> =
|
||||
codex_core::parse_command::parse_command(&command)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
@@ -413,7 +417,7 @@ fn active_blob(chat: &ChatWidget) -> String {
|
||||
lines_to_single_string(&lines)
|
||||
}
|
||||
|
||||
fn open_fixture(name: &str) -> std::fs::File {
|
||||
fn open_fixture(name: &str) -> File {
|
||||
// 1) Prefer fixtures within this crate
|
||||
{
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -621,7 +625,7 @@ async fn binary_size_transcript_snapshot() {
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 2000;
|
||||
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
@@ -806,7 +810,7 @@ fn approval_modal_exec_snapshot() {
|
||||
// Build a chat widget with manual channels to avoid spawning the agent.
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
||||
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
|
||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
||||
// Inject an exec approval request to display the approval modal.
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd".into(),
|
||||
@@ -836,7 +840,7 @@ fn approval_modal_exec_snapshot() {
|
||||
#[test]
|
||||
fn approval_modal_exec_without_reason_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
|
||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
||||
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd-noreason".into(),
|
||||
@@ -862,10 +866,10 @@ fn approval_modal_exec_without_reason_snapshot() {
|
||||
#[test]
|
||||
fn approval_modal_patch_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
|
||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
||||
|
||||
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
||||
let mut changes = std::collections::HashMap::new();
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("README.md"),
|
||||
FileChange::Add {
|
||||
@@ -911,7 +915,7 @@ fn interrupt_restores_queued_messages_into_composer() {
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
|
||||
reason: codex_core::protocol::TurnAbortReason::Interrupted,
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1345,7 +1349,7 @@ fn apply_patch_full_flow_integration_like() {
|
||||
fn apply_patch_untrusted_shows_approval_modal() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
// Ensure approval policy is untrusted (OnRequest)
|
||||
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
|
||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
||||
|
||||
// Simulate a patch approval request from backend
|
||||
let mut changes = HashMap::new();
|
||||
@@ -1364,8 +1368,8 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
});
|
||||
|
||||
// Render and ensure the approval modal title is present
|
||||
let area = ratatui::layout::Rect::new(0, 0, 80, 12);
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
let area = Rect::new(0, 0, 80, 12);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
|
||||
let mut contains_title = false;
|
||||
@@ -1390,7 +1394,7 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
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;
|
||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
||||
|
||||
// Simulate backend asking to apply a patch adding two lines to README.md
|
||||
let mut changes = HashMap::new();
|
||||
@@ -1692,7 +1696,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
let width: u16 = 80;
|
||||
let ui_height: u16 = chat.desired_height(width);
|
||||
let vt_height: u16 = 40;
|
||||
let viewport = ratatui::layout::Rect::new(0, vt_height - ui_height, width, ui_height);
|
||||
let viewport = Rect::new(0, vt_height - ui_height, width, ui_height);
|
||||
|
||||
// Use TestBackend for the terminal (no real ANSI emitted by drawing),
|
||||
// but capture VT100 escape stream for history insertion with a separate writer.
|
||||
@@ -1707,7 +1711,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
}
|
||||
|
||||
// 2) Render the ChatWidget UI into an off-screen buffer using WidgetRef directly
|
||||
let mut ui_buf = ratatui::buffer::Buffer::empty(viewport);
|
||||
let mut ui_buf = Buffer::empty(viewport);
|
||||
(&chat).render_ref(viewport, &mut ui_buf);
|
||||
|
||||
// 3) Build VT100 visual from the captured ANSI
|
||||
|
||||
@@ -20,7 +20,12 @@ pub struct Cli {
|
||||
/// - Mutually exclusive with `--continue`.
|
||||
/// - The picker displays recent sessions and a preview of the first real user
|
||||
/// message to help you select the right one.
|
||||
#[arg(long = "resume", default_value_t = false, conflicts_with = "continue")]
|
||||
#[arg(
|
||||
long = "resume",
|
||||
default_value_t = false,
|
||||
conflicts_with = "continue",
|
||||
hide = true
|
||||
)]
|
||||
pub resume: bool,
|
||||
|
||||
/// Continue the most recent conversation without showing the picker.
|
||||
@@ -33,7 +38,8 @@ pub struct Cli {
|
||||
id = "continue",
|
||||
long = "continue",
|
||||
default_value_t = false,
|
||||
conflicts_with = "resume"
|
||||
conflicts_with = "resume",
|
||||
hide = true
|
||||
)]
|
||||
pub r#continue: bool,
|
||||
|
||||
|
||||
@@ -49,34 +49,60 @@ pub struct PastedImageInfo {
|
||||
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
|
||||
let _span = tracing::debug_span!("paste_image_as_png").entered();
|
||||
tracing::debug!("attempting clipboard image read");
|
||||
let mut cb = arboard::Clipboard::new()
|
||||
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
|
||||
let img = cb
|
||||
.get_image()
|
||||
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
|
||||
let w = img.width as u32;
|
||||
let h = img.height as u32;
|
||||
// Sometimes images on the clipboard come as files (e.g. when copy/pasting from
|
||||
// Finder), sometimes they come as image data (e.g. when pasting from Chrome).
|
||||
// Accept both, and prefer files if both are present.
|
||||
let files = cb
|
||||
.get()
|
||||
.file_list()
|
||||
.map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()));
|
||||
let dyn_img = if let Some(img) = files
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find_map(|f| image::open(f).ok())
|
||||
{
|
||||
tracing::debug!(
|
||||
"clipboard image opened from file: {}x{}",
|
||||
img.width(),
|
||||
img.height()
|
||||
);
|
||||
img
|
||||
} else {
|
||||
let _span = tracing::debug_span!("get_image").entered();
|
||||
let img = cb
|
||||
.get_image()
|
||||
.map_err(|e| PasteImageError::NoImage(e.to_string()))?;
|
||||
let w = img.width as u32;
|
||||
let h = img.height as u32;
|
||||
tracing::debug!("clipboard image opened from image: {}x{}", w, h);
|
||||
|
||||
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
|
||||
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
|
||||
};
|
||||
|
||||
image::DynamicImage::ImageRgba8(rgba_img)
|
||||
};
|
||||
|
||||
let mut png: Vec<u8> = Vec::new();
|
||||
let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
|
||||
return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
|
||||
};
|
||||
let dyn_img = image::DynamicImage::ImageRgba8(rgba_img);
|
||||
tracing::debug!("clipboard image decoded RGBA {w}x{h}");
|
||||
{
|
||||
let span =
|
||||
tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered();
|
||||
let mut cursor = std::io::Cursor::new(&mut png);
|
||||
dyn_img
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
|
||||
span.record("byte_length", png.len());
|
||||
}
|
||||
|
||||
tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len());
|
||||
Ok((
|
||||
png,
|
||||
PastedImageInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
width: dyn_img.width(),
|
||||
height: dyn_img.height(),
|
||||
encoded_format: EncodedImageFormat::Png,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -2,9 +2,14 @@ use crate::diff_render::create_diff_summary;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use base64::Engine;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::create_config_summary_entries;
|
||||
@@ -22,6 +27,8 @@ use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::num_format::format_with_separators;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
@@ -44,7 +51,6 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::error;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CommandOutput {
|
||||
@@ -97,8 +103,7 @@ impl HistoryCell for UserHistoryCell {
|
||||
let wrapped = textwrap::wrap(
|
||||
&self.message,
|
||||
textwrap::Options::new(wrap_width as usize)
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) // Match textarea wrap
|
||||
.word_splitter(textwrap::WordSplitter::NoHyphenation),
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), // Match textarea wrap
|
||||
);
|
||||
|
||||
for line in wrapped {
|
||||
@@ -132,28 +137,16 @@ impl AgentMessageCell {
|
||||
|
||||
impl HistoryCell for AgentMessageCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
// We want:
|
||||
// - First visual line: "> " prefix (collapse with header logic)
|
||||
// - All subsequent visual lines: two-space prefix
|
||||
let mut is_first_visual = true;
|
||||
let wrap_width = width.saturating_sub(2); // account for prefix
|
||||
for line in &self.lines {
|
||||
let wrapped =
|
||||
crate::insert_history::word_wrap_lines(std::slice::from_ref(line), wrap_width);
|
||||
for (i, piece) in wrapped.into_iter().enumerate() {
|
||||
let mut spans = Vec::with_capacity(piece.spans.len() + 1);
|
||||
spans.push(if is_first_visual && i == 0 && self.is_first_line {
|
||||
word_wrap_lines(
|
||||
&self.lines,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(if self.is_first_line {
|
||||
"> ".into()
|
||||
} else {
|
||||
" ".into()
|
||||
});
|
||||
spans.extend(piece.spans.into_iter());
|
||||
out.push(spans.into());
|
||||
}
|
||||
is_first_visual = false;
|
||||
}
|
||||
out
|
||||
})
|
||||
.subsequent_indent(" ".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
@@ -278,13 +271,13 @@ impl ExecCell {
|
||||
}
|
||||
|
||||
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
let active_start_time = self
|
||||
.calls
|
||||
.iter()
|
||||
.find(|c| c.output.is_none())
|
||||
.and_then(|c| c.start_time);
|
||||
lines.push(Line::from(vec![
|
||||
out.push(Line::from(vec![
|
||||
if self.is_active() {
|
||||
// Show an animated spinner while exploring
|
||||
spinner(active_start_time)
|
||||
@@ -299,7 +292,7 @@ impl ExecCell {
|
||||
},
|
||||
]));
|
||||
let mut calls = self.calls.clone();
|
||||
let mut first = true;
|
||||
let mut out_indented = Vec::new();
|
||||
while !calls.is_empty() {
|
||||
let mut call = calls.remove(0);
|
||||
if call
|
||||
@@ -372,39 +365,24 @@ impl ExecCell {
|
||||
lines
|
||||
};
|
||||
for (title, line) in call_lines {
|
||||
let prefix_len = 4 + title.len() + 1; // " └ " + title + " "
|
||||
let wrapped = crate::insert_history::word_wrap_lines(
|
||||
&[line.into()],
|
||||
width.saturating_sub(prefix_len as u16),
|
||||
let line = Line::from(line);
|
||||
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
|
||||
let subsequent_indent = " ".repeat(initial_indent.width()).into();
|
||||
let wrapped = word_wrap_line(
|
||||
&line,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(initial_indent)
|
||||
.subsequent_indent(subsequent_indent),
|
||||
);
|
||||
let mut first_sub = true;
|
||||
for mut line in wrapped {
|
||||
let mut spans = Vec::with_capacity(line.spans.len() + 1);
|
||||
spans.push(if first {
|
||||
first = false;
|
||||
" └ ".dim()
|
||||
} else {
|
||||
" ".into()
|
||||
});
|
||||
if first_sub {
|
||||
first_sub = false;
|
||||
spans.push(title.cyan());
|
||||
spans.push(" ".into());
|
||||
} else {
|
||||
spans.push(" ".repeat(title.width() + 1).into());
|
||||
}
|
||||
spans.extend(line.spans.into_iter());
|
||||
line.spans = spans;
|
||||
lines.push(line);
|
||||
}
|
||||
push_owned_lines(&wrapped, &mut out_indented);
|
||||
}
|
||||
}
|
||||
lines
|
||||
out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into()));
|
||||
out
|
||||
}
|
||||
|
||||
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
use textwrap::Options as TwOptions;
|
||||
use textwrap::WordSplitter;
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let [call] = &self.calls.as_slice() else {
|
||||
@@ -424,38 +402,28 @@ impl ExecCell {
|
||||
// "• Running " (including trailing space) as the reserved prefix width.
|
||||
// If the command contains newlines, always use the multi-line variant.
|
||||
let reserved = "• Running ".width();
|
||||
let mut branch_consumed = false;
|
||||
|
||||
if !cmd_display.contains('\n')
|
||||
&& cmd_display.width() < (width as usize).saturating_sub(reserved)
|
||||
let mut body_lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd_display);
|
||||
|
||||
if highlighted_lines.len() == 1
|
||||
&& highlighted_lines[0].width() < (width as usize).saturating_sub(reserved)
|
||||
{
|
||||
lines.push(Line::from(vec![
|
||||
bullet,
|
||||
" ".into(),
|
||||
title.bold(),
|
||||
" ".into(),
|
||||
cmd_display.clone().into(),
|
||||
]));
|
||||
let mut line = Line::from(vec![bullet, " ".into(), title.bold(), " ".into()]);
|
||||
line.extend(highlighted_lines[0].clone());
|
||||
lines.push(line);
|
||||
} else {
|
||||
branch_consumed = true;
|
||||
lines.push(vec![bullet, " ".into(), title.bold()].into());
|
||||
|
||||
// Wrap the command line.
|
||||
for (i, line) in cmd_display.lines().enumerate() {
|
||||
let wrapped = textwrap::wrap(
|
||||
line,
|
||||
TwOptions::new(width as usize)
|
||||
.initial_indent(" ")
|
||||
.subsequent_indent(" ")
|
||||
.word_splitter(WordSplitter::NoHyphenation),
|
||||
);
|
||||
lines.extend(wrapped.into_iter().enumerate().map(|(j, l)| {
|
||||
if i == 0 && j == 0 {
|
||||
vec![" └ ".dim(), l[4..].to_string().into()].into()
|
||||
} else {
|
||||
l.to_string().into()
|
||||
}
|
||||
}));
|
||||
for hl_line in highlighted_lines.iter() {
|
||||
let opts = crate::wrapping::RtOptions::new((width as usize).saturating_sub(4))
|
||||
.initial_indent("".into())
|
||||
.subsequent_indent(" ".into())
|
||||
// Hyphenation likes to break words on hyphens, which is bad for bash scripts --because-of-flags.
|
||||
.word_splitter(textwrap::WordSplitter::NoHyphenation);
|
||||
let wrapped_borrowed = crate::wrapping::word_wrap_line(hl_line, opts);
|
||||
body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l)));
|
||||
}
|
||||
}
|
||||
if let Some(output) = call.output.as_ref()
|
||||
@@ -466,25 +434,13 @@ impl ExecCell {
|
||||
.join("\n");
|
||||
if !out.trim().is_empty() {
|
||||
// Wrap the output.
|
||||
for (i, line) in out.lines().enumerate() {
|
||||
let wrapped = textwrap::wrap(
|
||||
line,
|
||||
TwOptions::new(width as usize - 4)
|
||||
.word_splitter(WordSplitter::NoHyphenation),
|
||||
);
|
||||
lines.extend(wrapped.into_iter().map(|l| {
|
||||
Line::from(vec![
|
||||
if i == 0 && !branch_consumed {
|
||||
" └ ".dim()
|
||||
} else {
|
||||
" ".dim()
|
||||
},
|
||||
l.to_string().dim(),
|
||||
])
|
||||
}));
|
||||
for line in out.lines() {
|
||||
let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4));
|
||||
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
@@ -866,7 +822,7 @@ pub(crate) fn new_completed_mcp_tool_call(
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
session_id: &Option<Uuid>,
|
||||
session_id: &Option<ConversationId>,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push("/status".magenta().into());
|
||||
@@ -995,6 +951,12 @@ pub(crate) fn new_status_output(
|
||||
|
||||
lines.push("".into());
|
||||
|
||||
// 💻 Client
|
||||
let cli_version = crate::version::CODEX_CLI_VERSION;
|
||||
lines.push(vec![padded_emoji("💻").into(), "Client".bold()].into());
|
||||
lines.push(vec![" • CLI Version: ".into(), cli_version.into()].into());
|
||||
lines.push("".into());
|
||||
|
||||
// 📊 Token Usage
|
||||
lines.push(vec!["📊 ".into(), "Token Usage".bold()].into());
|
||||
if let Some(session_id) = session_id {
|
||||
@@ -1003,23 +965,22 @@ pub(crate) fn new_status_output(
|
||||
// Input: <input> [+ <cached> cached]
|
||||
let mut input_line_spans: Vec<Span<'static>> = vec![
|
||||
" • Input: ".into(),
|
||||
usage.non_cached_input().to_string().into(),
|
||||
format_with_separators(usage.non_cached_input()).into(),
|
||||
];
|
||||
if let Some(cached) = usage.cached_input_tokens
|
||||
&& cached > 0
|
||||
{
|
||||
if usage.cached_input_tokens > 0 {
|
||||
let cached = usage.cached_input_tokens;
|
||||
input_line_spans.push(format!(" (+ {cached} cached)").into());
|
||||
}
|
||||
lines.push(Line::from(input_line_spans));
|
||||
// Output: <output>
|
||||
lines.push(Line::from(vec![
|
||||
" • Output: ".into(),
|
||||
usage.output_tokens.to_string().into(),
|
||||
format_with_separators(usage.output_tokens).into(),
|
||||
]));
|
||||
// Total: <total>
|
||||
lines.push(Line::from(vec![
|
||||
" • Total: ".into(),
|
||||
usage.blended_total().to_string().into(),
|
||||
format_with_separators(usage.blended_total()).into(),
|
||||
]));
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
@@ -1217,13 +1178,14 @@ pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(vec!["• ".into(), "Proposed Command".bold()]));
|
||||
|
||||
let cmd_lines: Vec<Line<'static>> = cmd
|
||||
.lines()
|
||||
.map(|part| Line::from(part.to_string()))
|
||||
.collect();
|
||||
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd);
|
||||
let initial_prefix: Span<'static> = " └ ".dim();
|
||||
let subsequent_prefix: Span<'static> = " ".into();
|
||||
lines.extend(prefix_lines(cmd_lines, initial_prefix, subsequent_prefix));
|
||||
lines.extend(prefix_lines(
|
||||
highlighted_lines,
|
||||
initial_prefix,
|
||||
subsequent_prefix,
|
||||
));
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::tui;
|
||||
use crate::wrapping::word_wrap_lines_borrowed;
|
||||
use crossterm::Command;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::queue;
|
||||
@@ -18,8 +19,6 @@ use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use textwrap::Options as TwOptions;
|
||||
use textwrap::WordSplitter;
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
/// (avoids direct stdout references).
|
||||
@@ -44,7 +43,7 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
|
||||
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
|
||||
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
|
||||
let wrapped = word_wrap_lines(&lines, area.width.max(1));
|
||||
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
|
||||
let wrapped_lines = wrapped.len() as u16;
|
||||
let cursor_top = if area.bottom() < screen_size.height {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
@@ -98,7 +97,7 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
|
||||
for line in wrapped {
|
||||
queue!(writer, Print("\r\n")).ok();
|
||||
write_spans(writer, line.iter()).ok();
|
||||
write_spans(writer, &line).ok();
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion).ok();
|
||||
@@ -223,7 +222,7 @@ impl ModifierDiff {
|
||||
|
||||
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = &'a Span<'a>>,
|
||||
I: IntoIterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
@@ -262,129 +261,6 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// Word-aware wrapping for a list of `Line`s preserving styles.
|
||||
pub(crate) fn word_wrap_lines<'a, I>(lines: I, width: u16) -> Vec<Line<'static>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a Line<'a>>,
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
let w = width.max(1) as usize;
|
||||
for line in lines {
|
||||
out.extend(word_wrap_line(line, w));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
|
||||
if width == 0 {
|
||||
return vec![to_owned_line(line)];
|
||||
}
|
||||
// Concatenate content and keep span boundaries for later re-slicing.
|
||||
let mut flat = String::new();
|
||||
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
|
||||
let mut cursor = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let start = cursor;
|
||||
flat.push_str(text);
|
||||
cursor += text.len();
|
||||
span_bounds.push((start, cursor, s.style));
|
||||
}
|
||||
|
||||
// Use textwrap for robust word-aware wrapping; no hyphenation, no breaking words.
|
||||
let opts = TwOptions::new(width)
|
||||
.break_words(false)
|
||||
.word_splitter(WordSplitter::NoHyphenation);
|
||||
let wrapped = textwrap::wrap(&flat, &opts);
|
||||
|
||||
if wrapped.len() <= 1 {
|
||||
return vec![to_owned_line(line)];
|
||||
}
|
||||
|
||||
// Map wrapped pieces back to byte ranges in `flat` sequentially.
|
||||
let mut start_cursor = 0usize;
|
||||
let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
|
||||
for piece in wrapped {
|
||||
let piece_str: &str = &piece;
|
||||
if piece_str.is_empty() {
|
||||
out.push(Line {
|
||||
style: line.style,
|
||||
alignment: line.alignment,
|
||||
spans: Vec::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Find the next occurrence of piece_str at or after start_cursor.
|
||||
// textwrap preserves order, so a linear scan is sufficient.
|
||||
if let Some(rel) = flat[start_cursor..].find(piece_str) {
|
||||
let s = start_cursor + rel;
|
||||
let e = s + piece_str.len();
|
||||
out.push(slice_line_spans(line, &span_bounds, s, e));
|
||||
start_cursor = e;
|
||||
} else {
|
||||
// Fallback: slice by length from cursor.
|
||||
let s = start_cursor;
|
||||
let e = (start_cursor + piece_str.len()).min(flat.len());
|
||||
out.push(slice_line_spans(line, &span_bounds, s, e));
|
||||
start_cursor = e;
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn to_owned_line(l: &Line<'_>) -> Line<'static> {
|
||||
Line {
|
||||
style: l.style,
|
||||
alignment: l.alignment,
|
||||
spans: l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style,
|
||||
content: std::borrow::Cow::Owned(s.content.to_string()),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn slice_line_spans(
|
||||
original: &Line<'_>,
|
||||
span_bounds: &[(usize, usize, ratatui::style::Style)],
|
||||
start_byte: usize,
|
||||
end_byte: usize,
|
||||
) -> Line<'static> {
|
||||
let mut acc: Vec<Span<'static>> = Vec::new();
|
||||
for (i, (s, e, style)) in span_bounds.iter().enumerate() {
|
||||
if *e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
if *s >= end_byte {
|
||||
break;
|
||||
}
|
||||
let seg_start = start_byte.max(*s);
|
||||
let seg_end = end_byte.min(*e);
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - *s;
|
||||
let local_end = seg_end - *s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Owned(slice.to_string()),
|
||||
});
|
||||
}
|
||||
if *e >= end_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line {
|
||||
style: original.style,
|
||||
alignment: original.alignment,
|
||||
spans: acc,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -416,38 +292,4 @@ mod tests {
|
||||
String::from_utf8(expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_counts_double_width_emoji() {
|
||||
let line = "😀😀😀".into(); // each emoji ~ width 2
|
||||
assert_eq!(word_wrap_line(&line, 4).len(), 2);
|
||||
assert_eq!(word_wrap_line(&line, 2).len(), 3);
|
||||
assert_eq!(word_wrap_line(&line, 6).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_does_not_split_words_simple_english() {
|
||||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||||
let line = sample.into();
|
||||
// Force small width to exercise wrapping at spaces.
|
||||
let wrapped = word_wrap_lines(&[line], 40);
|
||||
let joined: String = wrapped
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!joined.contains("bo\nth"),
|
||||
"word 'both' should not be split across lines:\n{joined}"
|
||||
);
|
||||
assert!(
|
||||
!joined.contains("Willowm\nere"),
|
||||
"should not split inside words:\n{joined}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ mod streaming;
|
||||
mod text_formatting;
|
||||
mod tui;
|
||||
mod user_approval_widget;
|
||||
mod version;
|
||||
mod wrapping;
|
||||
|
||||
// Internal vt100-based replay tests live as a separate source file to keep them
|
||||
// close to the widget code. Include them in unit tests.
|
||||
@@ -129,7 +131,6 @@ pub async fn run_main(
|
||||
include_plan_tool: Some(true),
|
||||
include_apply_patch_tool: None,
|
||||
include_view_image_tool: None,
|
||||
disable_response_storage: cli.oss.then_some(true),
|
||||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||||
tools_web_search_request: cli.web_search.then_some(true),
|
||||
};
|
||||
@@ -216,6 +217,7 @@ pub async fn run_main(
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_target(false)
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
|
||||
.with_filter(env_filter());
|
||||
|
||||
if cli.oss {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::io::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::insert_history;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -287,9 +287,9 @@ impl PagerView {
|
||||
let mut wrapped: Vec<Line<'static>> = Vec::new();
|
||||
let mut src_idx: Vec<usize> = Vec::new();
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
let ws = insert_history::word_wrap_lines(std::slice::from_ref(line), width);
|
||||
let ws = crate::wrapping::word_wrap_line(line, width as usize);
|
||||
src_idx.extend(std::iter::repeat_n(i, ws.len()));
|
||||
wrapped.extend(ws);
|
||||
push_owned_lines(&ws, &mut wrapped);
|
||||
}
|
||||
self.wrap_cache = Some(WrapCache {
|
||||
width,
|
||||
|
||||
145
codex-rs/tui/src/render/highlight.rs
Normal file
145
codex-rs/tui/src/render/highlight.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use codex_core::bash::try_parse_bash;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Convert the full bash script into per-line styled content by first
|
||||
/// computing operator-dimmed spans across the entire script, then splitting
|
||||
/// by newlines and dimming heredoc body lines. Performs a single parse and
|
||||
/// reuses it for both highlighting and heredoc detection.
|
||||
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
|
||||
// Parse once; use the tree for both highlighting and heredoc body detection.
|
||||
let spans: Vec<Span<'static>> = if let Some(tree) = try_parse_bash(script) {
|
||||
// Single walk: collect operator ranges and heredoc rows.
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
let mut stack = vec![root];
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
while let Some(node) = stack.pop() {
|
||||
if !node.is_named() && !node.is_extra() {
|
||||
let kind = node.kind();
|
||||
let is_quote = matches!(kind, "\"" | "'" | "`");
|
||||
let is_whitespace = kind.trim().is_empty();
|
||||
if !is_quote && !is_whitespace {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
} else if node.kind() == "heredoc_body" {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
for child in node.children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
if ranges.is_empty() {
|
||||
ranges.push((script.len(), script.len()));
|
||||
}
|
||||
ranges.sort_by_key(|(st, _)| *st);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
for (start, end) in ranges.into_iter() {
|
||||
let dim_start = start.max(i);
|
||||
let dim_end = end;
|
||||
if dim_start < dim_end {
|
||||
if dim_start > i {
|
||||
spans.push(script[i..dim_start].to_string().into());
|
||||
}
|
||||
spans.push(script[dim_start..dim_end].to_string().dim());
|
||||
i = dim_end;
|
||||
}
|
||||
}
|
||||
if i < script.len() {
|
||||
spans.push(script[i..].to_string().into());
|
||||
}
|
||||
spans
|
||||
} else {
|
||||
vec![script.to_string().into()]
|
||||
};
|
||||
// Split spans into lines preserving style boundaries and highlights across newlines.
|
||||
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
||||
for sp in spans {
|
||||
let style = sp.style;
|
||||
let text = sp.content.into_owned();
|
||||
for (i, part) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let span = Span {
|
||||
style,
|
||||
content: std::borrow::Cow::Owned(part.to_string()),
|
||||
};
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
#[test]
|
||||
fn dims_expected_bash_operators() {
|
||||
let s = "echo foo && bar || baz | qux & (echo hi)";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(dimmed, vec!["&&", "||", "|", "&", "(", ")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_dim_quotes_but_dims_other_punct() {
|
||||
let s = "echo \"hi\" > out.txt; echo 'ok'";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.collect();
|
||||
assert!(dimmed.contains(&">".to_string()));
|
||||
assert!(dimmed.contains(&";".to_string()));
|
||||
assert!(!dimmed.contains(&"\"".to_string()));
|
||||
assert!(!dimmed.contains(&"'".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod highlight;
|
||||
pub mod line_utils;
|
||||
pub mod markdown_utils;
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_core::ConversationItem;
|
||||
use codex_core::ConversationsPage;
|
||||
use codex_core::Cursor;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -24,6 +23,10 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InputMessageKind;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
|
||||
@@ -273,7 +276,7 @@ fn head_to_row(item: &ConversationItem) -> Option<Row> {
|
||||
ts = Some(parsed.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
let preview = find_first_user_text(&item.head)?;
|
||||
let preview = preview_from_head(&item.head)?;
|
||||
let preview = preview.trim().to_string();
|
||||
if preview.is_empty() {
|
||||
return None;
|
||||
@@ -285,37 +288,42 @@ fn head_to_row(item: &ConversationItem) -> Option<Row> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the first plain user text from the JSONL `head` of a rollout.
|
||||
///
|
||||
/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and
|
||||
/// then return the first `content` item where `{ type: "input_text" }` that is
|
||||
/// classified as `InputMessageKind::Plain` (i.e., not wrapped in
|
||||
/// `<user_instructions>` or `<environment_context>` tags).
|
||||
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
|
||||
for v in head.iter() {
|
||||
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if t != "message" {
|
||||
continue;
|
||||
}
|
||||
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
for c in arr.iter() {
|
||||
if let (Some("input_text"), Some(txt)) =
|
||||
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
|
||||
&& let Some(s) = txt.as_str()
|
||||
{
|
||||
// Skip XML-wrapped user_instructions/environment_context blocks and
|
||||
// return the first plain user text we find.
|
||||
if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
fn preview_from_head(head: &[serde_json::Value]) -> Option<String> {
|
||||
head.iter()
|
||||
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
// Find the actual user message (as opposed to user instructions or ide context)
|
||||
let preview = content
|
||||
.into_iter()
|
||||
.filter_map(|content| match content {
|
||||
ContentItem::InputText { text }
|
||||
if matches!(
|
||||
InputMessageKind::from(("user", text.as_str())),
|
||||
InputMessageKind::Plain
|
||||
) =>
|
||||
{
|
||||
// Strip ide context.
|
||||
let text = match text.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => {
|
||||
text[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string()
|
||||
}
|
||||
None => text,
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
@@ -452,31 +460,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_user_instructions_and_env_context() {
|
||||
fn preview_uses_first_message_input_text() {
|
||||
let head = vec![
|
||||
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" }
|
||||
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" },
|
||||
{ "type": "input_text", "text": "real question" },
|
||||
{ "type": "input_image", "image_url": "ignored" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<environment_context>cwd</environment_context>" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "real question" } ]
|
||||
"content": [ { "type": "input_text", "text": "later text" } ]
|
||||
}),
|
||||
];
|
||||
let first = find_first_user_text(&head);
|
||||
assert_eq!(first.as_deref(), Some("real question"));
|
||||
let preview = preview_from_head(&head);
|
||||
assert_eq!(preview.as_deref(), Some("real question"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1942
|
||||
expression: rendered
|
||||
---
|
||||
• Ran
|
||||
└ first_token_is_long_
|
||||
enough_to_wrap
|
||||
second_token_is_also
|
||||
_long_enough_to_wrap
|
||||
└ first_token_is_long_enou
|
||||
gh_to_wrap
|
||||
second_token_is_also_lon
|
||||
g_enough_to_wrap
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1797
|
||||
expression: rendered
|
||||
---
|
||||
• Ran
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1869
|
||||
expression: rendered
|
||||
---
|
||||
• Ran
|
||||
└ a_very_long_toke
|
||||
n_without_spaces
|
||||
_to_force_wrappi
|
||||
ng
|
||||
└ a_very_long_token_wi
|
||||
thout_spaces_to_
|
||||
force_wrapping
|
||||
|
||||
@@ -17,8 +17,6 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use textwrap::Options as TwOptions;
|
||||
use textwrap::WordSplitter;
|
||||
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
/// Animated header text (defaults to "Working").
|
||||
@@ -33,6 +31,23 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
frame_requester: FrameRequester,
|
||||
}
|
||||
|
||||
// Format elapsed seconds into a compact human-friendly form used by the status line.
|
||||
// Examples: 0s, 59s, 1m00s, 59m59s, 1h00m00s, 2h03m09s
|
||||
fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
|
||||
if elapsed_secs < 60 {
|
||||
return format!("{elapsed_secs}s");
|
||||
}
|
||||
if elapsed_secs < 3600 {
|
||||
let minutes = elapsed_secs / 60;
|
||||
let seconds = elapsed_secs % 60;
|
||||
return format!("{minutes}m{seconds:02}s");
|
||||
}
|
||||
let hours = elapsed_secs / 3600;
|
||||
let minutes = (elapsed_secs % 3600) / 60;
|
||||
let seconds = elapsed_secs % 60;
|
||||
format!("{hours}h{minutes:02}m{seconds:02}s")
|
||||
}
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
@@ -54,11 +69,8 @@ impl StatusIndicatorWidget {
|
||||
let mut total: u16 = 1; // status line
|
||||
let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix
|
||||
if text_width > 0 {
|
||||
let opts = TwOptions::new(text_width)
|
||||
.break_words(false)
|
||||
.word_splitter(WordSplitter::NoHyphenation);
|
||||
for q in &self.queued_messages {
|
||||
let wrapped = textwrap::wrap(q, &opts);
|
||||
let wrapped = textwrap::wrap(q, text_width);
|
||||
let lines = wrapped.len().min(3) as u16;
|
||||
total = total.saturating_add(lines);
|
||||
if wrapped.len() > 3 {
|
||||
@@ -141,13 +153,14 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(Duration::from_millis(32));
|
||||
let elapsed = self.elapsed_seconds();
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed);
|
||||
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let mut spans = vec![" ".into()];
|
||||
spans.extend(shimmer_spans(&self.header));
|
||||
spans.extend(vec![
|
||||
" ".into(),
|
||||
format!("({elapsed}s • ").dim(),
|
||||
format!("({pretty_elapsed} • ").dim(),
|
||||
"Esc".dim().bold(),
|
||||
" to interrupt)".dim(),
|
||||
]);
|
||||
@@ -157,11 +170,8 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
lines.push(Line::from(spans));
|
||||
// Wrap queued messages using textwrap and show up to the first 3 lines per message.
|
||||
let text_width = area.width.saturating_sub(3); // " ↳ " prefix
|
||||
let opts = TwOptions::new(text_width as usize)
|
||||
.break_words(false)
|
||||
.word_splitter(WordSplitter::NoHyphenation);
|
||||
for q in &self.queued_messages {
|
||||
let wrapped = textwrap::wrap(q, &opts);
|
||||
let wrapped = textwrap::wrap(q, text_width as usize);
|
||||
for (i, piece) in wrapped.iter().take(3).enumerate() {
|
||||
let prefix = if i == 0 { " ↳ " } else { " " };
|
||||
let content = format!("{prefix}{piece}");
|
||||
@@ -192,6 +202,22 @@ mod tests {
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn fmt_elapsed_compact_formats_seconds_minutes_hours() {
|
||||
assert_eq!(fmt_elapsed_compact(0), "0s");
|
||||
assert_eq!(fmt_elapsed_compact(1), "1s");
|
||||
assert_eq!(fmt_elapsed_compact(59), "59s");
|
||||
assert_eq!(fmt_elapsed_compact(60), "1m00s");
|
||||
assert_eq!(fmt_elapsed_compact(61), "1m01s");
|
||||
assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m05s");
|
||||
assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m59s");
|
||||
assert_eq!(fmt_elapsed_compact(3600), "1h00m00s");
|
||||
assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h01m01s");
|
||||
assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h02m03s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_with_working_header() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -20,10 +19,7 @@ use crossterm::cursor::MoveTo;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::event::PushKeyboardEnhancementFlags;
|
||||
@@ -38,7 +34,6 @@ use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use tokio::select;
|
||||
@@ -154,12 +149,6 @@ pub enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
Paste(String),
|
||||
Draw,
|
||||
AttachImage {
|
||||
path: PathBuf,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format_label: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Tui {
|
||||
@@ -305,29 +294,6 @@ impl Tui {
|
||||
select! {
|
||||
Some(Ok(event)) = crossterm_events.next() => {
|
||||
match event {
|
||||
// Detect Ctrl+V to attach an image from the clipboard.
|
||||
Event::Key(key_event @ KeyEvent {
|
||||
code: KeyCode::Char('v'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => {
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
yield TuiEvent::AttachImage {
|
||||
path,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
format_label: info.encoded_format.label(),
|
||||
};
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall back to normal key handling if no image is available.
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
#[cfg(unix)]
|
||||
if matches!(
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::path::PathBuf;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::create_client;
|
||||
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
|
||||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
let version_file = version_filepath(config);
|
||||
let info = read_version_info(&version_file).ok();
|
||||
@@ -31,8 +33,7 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
}
|
||||
|
||||
info.and_then(|info| {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
if is_newer(&info.latest_version, current_version).unwrap_or(false) {
|
||||
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
|
||||
Some(info.latest_version)
|
||||
} else {
|
||||
None
|
||||
|
||||
2
codex-rs/tui/src/version.rs
Normal file
2
codex-rs/tui/src/version.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
/// The current Codex CLI version as embedded at compile time.
|
||||
pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
545
codex-rs/tui/src/wrapping.rs
Normal file
545
codex-rs/tui/src/wrapping.rs
Normal file
@@ -0,0 +1,545 @@
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||||
where
|
||||
O: Into<Options<'a>>,
|
||||
{
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
let end = start + slice.len();
|
||||
let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
|
||||
lines.push(start..end + trailing_spaces + 1);
|
||||
}
|
||||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Like `wrap_ranges` but returns ranges without trailing whitespace and
|
||||
/// without the sentinel extra byte. Suitable for general wrapping where
|
||||
/// trailing spaces should not be preserved.
|
||||
pub(crate) fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||||
where
|
||||
O: Into<Options<'a>>,
|
||||
{
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
let end = start + slice.len();
|
||||
lines.push(start..end);
|
||||
}
|
||||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RtOptions<'a> {
|
||||
/// The width in columns at which the text will be wrapped.
|
||||
pub width: usize,
|
||||
/// Line ending used for breaking lines.
|
||||
pub line_ending: textwrap::LineEnding,
|
||||
/// Indentation used for the first line of output. See the
|
||||
/// [`Options::initial_indent`] method.
|
||||
pub initial_indent: Line<'a>,
|
||||
/// Indentation used for subsequent lines of output. See the
|
||||
/// [`Options::subsequent_indent`] method.
|
||||
pub subsequent_indent: Line<'a>,
|
||||
/// Allow long words to be broken if they cannot fit on a line.
|
||||
/// When set to `false`, some lines may be longer than
|
||||
/// `self.width`. See the [`Options::break_words`] method.
|
||||
pub break_words: bool,
|
||||
/// Wrapping algorithm to use, see the implementations of the
|
||||
/// [`WrapAlgorithm`] trait for details.
|
||||
pub wrap_algorithm: textwrap::WrapAlgorithm,
|
||||
/// The line breaking algorithm to use, see the [`WordSeparator`]
|
||||
/// trait for an overview and possible implementations.
|
||||
pub word_separator: textwrap::WordSeparator,
|
||||
/// The method for splitting words. This can be used to prohibit
|
||||
/// splitting words on hyphens, or it can be used to implement
|
||||
/// language-aware machine hyphenation.
|
||||
pub word_splitter: textwrap::WordSplitter,
|
||||
}
|
||||
impl From<usize> for RtOptions<'_> {
|
||||
fn from(width: usize) -> Self {
|
||||
RtOptions::new(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<'a> RtOptions<'a> {
|
||||
pub fn new(width: usize) -> Self {
|
||||
RtOptions {
|
||||
width,
|
||||
line_ending: textwrap::LineEnding::LF,
|
||||
initial_indent: Line::default(),
|
||||
subsequent_indent: Line::default(),
|
||||
break_words: true,
|
||||
word_separator: textwrap::WordSeparator::new(),
|
||||
wrap_algorithm: textwrap::WrapAlgorithm::new(),
|
||||
word_splitter: textwrap::WordSplitter::HyphenSplitter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
|
||||
RtOptions {
|
||||
line_ending,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(self, width: usize) -> Self {
|
||||
RtOptions { width, ..self }
|
||||
}
|
||||
|
||||
pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
|
||||
RtOptions {
|
||||
initial_indent,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
|
||||
RtOptions {
|
||||
subsequent_indent,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn break_words(self, break_words: bool) -> Self {
|
||||
RtOptions {
|
||||
break_words,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
word_separator,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
wrap_algorithm,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
word_splitter,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
|
||||
where
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
// Flatten the line and record span byte ranges.
|
||||
let mut flat = String::new();
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
let opts = Options::new(rt_opts.width)
|
||||
.line_ending(rt_opts.line_ending)
|
||||
.break_words(rt_opts.break_words)
|
||||
.wrap_algorithm(rt_opts.wrap_algorithm)
|
||||
.word_separator(rt_opts.word_separator)
|
||||
.word_splitter(rt_opts.word_splitter);
|
||||
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
|
||||
// Compute first line range with reduced width due to initial indent.
|
||||
let initial_width_available = opts
|
||||
.width
|
||||
.saturating_sub(rt_opts.initial_indent.width())
|
||||
.max(1);
|
||||
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
|
||||
let Some(first_line_range) = initial_wrapped.first() else {
|
||||
return vec![rt_opts.initial_indent.clone()];
|
||||
};
|
||||
|
||||
// Build first wrapped line with initial indent.
|
||||
let mut first_line = rt_opts.initial_indent.clone();
|
||||
{
|
||||
let mut sliced = slice_line_spans(line, &span_bounds, first_line_range);
|
||||
let mut spans = first_line.spans;
|
||||
spans.append(&mut sliced.spans);
|
||||
first_line.spans = spans;
|
||||
out.push(first_line);
|
||||
}
|
||||
|
||||
// Wrap the remainder using subsequent indent width and map back to original indices.
|
||||
let base = first_line_range.end;
|
||||
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
|
||||
let base = base + skip_leading_spaces;
|
||||
let subsequent_width_available = opts
|
||||
.width
|
||||
.saturating_sub(rt_opts.subsequent_indent.width())
|
||||
.max(1);
|
||||
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
|
||||
for r in &remaining_wrapped {
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut subsequent_line = rt_opts.subsequent_indent.clone();
|
||||
let offset_range = (r.start + base)..(r.end + base);
|
||||
let mut sliced = slice_line_spans(line, &span_bounds, &offset_range);
|
||||
let mut spans = subsequent_line.spans;
|
||||
spans.append(&mut sliced.spans);
|
||||
subsequent_line.spans = spans;
|
||||
out.push(subsequent_line);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a sequence of lines, applying the initial indent only to the very first
|
||||
/// output line, and using the subsequent indent for all later wrapped pieces.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn word_wrap_lines<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'static>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a Line<'a>>,
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
for (idx, line) in lines.into_iter().enumerate() {
|
||||
let opts = if idx == 0 {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
let mut o = base_opts.clone();
|
||||
let sub = o.subsequent_indent.clone();
|
||||
o = o.initial_indent(sub);
|
||||
o
|
||||
};
|
||||
let wrapped = word_wrap_line(line, opts);
|
||||
push_owned_lines(&wrapped, &mut out);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a Line<'a>>,
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
let mut first = true;
|
||||
for line in lines.into_iter() {
|
||||
let opts = if first {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
base_opts
|
||||
.clone()
|
||||
.initial_indent(base_opts.subsequent_indent.clone())
|
||||
};
|
||||
out.extend(word_wrap_line(line, opts));
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
if s >= end_byte {
|
||||
break;
|
||||
}
|
||||
let seg_start = start_byte.max(s);
|
||||
let seg_end = end_byte.min(e);
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line {
|
||||
style: original.style,
|
||||
alignment: original.alignment,
|
||||
spans: acc,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
fn concat_line(line: &Line) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trivial_unstyled_no_indents_wide_width() {
|
||||
let line = Line::from("hello");
|
||||
let out = word_wrap_line(&line, 10);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_unstyled_wrap_narrow_width() {
|
||||
let line = Line::from("hello world");
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_styled_wrap_preserves_styles() {
|
||||
let line = Line::from(vec!["hello ".red(), "world".into()]);
|
||||
let out = word_wrap_line(&line, 6);
|
||||
assert_eq!(out.len(), 2);
|
||||
// First line should carry the red style
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(out[0].spans.len(), 1);
|
||||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||||
// Second line is unstyled
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
assert_eq!(out[1].spans.len(), 1);
|
||||
assert_eq!(out[1].spans[0].style.fg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_initial_and_subsequent_indents() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let line = Line::from("hello world foo");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
// Expect three lines with proper prefixes
|
||||
assert!(concat_line(&out[0]).starts_with("- "));
|
||||
assert!(concat_line(&out[1]).starts_with(" "));
|
||||
assert!(concat_line(&out[2]).starts_with(" "));
|
||||
// And content roughly segmented
|
||||
assert_eq!(concat_line(&out[0]), "- hello");
|
||||
assert_eq!(concat_line(&out[1]), " world");
|
||||
assert_eq!(concat_line(&out[2]), " foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_initial_indent_subsequent_spaces() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let line = Line::from("hello world foobar");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert!(concat_line(&out[0]).starts_with("hello"));
|
||||
for l in &out[1..] {
|
||||
assert!(concat_line(l).starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_single_empty_line() {
|
||||
let line = Line::from("");
|
||||
let out = word_wrap_line(&line, 10);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_preserved_on_first_line() {
|
||||
let line = Line::from(" hello");
|
||||
let out = word_wrap_line(&line, 8);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), " hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_spaces_between_words_dont_start_next_line_with_spaces() {
|
||||
let line = Line::from("hello world");
|
||||
let out = word_wrap_line(&line, 8);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_words_false_allows_overflow_for_long_word() {
|
||||
let opts = RtOptions::new(5).break_words(false);
|
||||
let line = Line::from("supercalifragilistic");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "supercalifragilistic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphen_splitter_breaks_at_hyphen() {
|
||||
let line = Line::from("hello-world");
|
||||
let out = word_wrap_line(&line, 7);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello-");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_consumes_width_leaving_one_char_space() {
|
||||
let opts = RtOptions::new(4)
|
||||
.initial_indent(Line::from(">>>>"))
|
||||
.subsequent_indent(Line::from("--"));
|
||||
let line = Line::from("hello");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert_eq!(out.len(), 3);
|
||||
assert_eq!(concat_line(&out[0]), ">>>>h");
|
||||
assert_eq!(concat_line(&out[1]), "--el");
|
||||
assert_eq!(concat_line(&out[2]), "--lo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_unicode_wraps_by_display_width() {
|
||||
let line = Line::from("😀😀😀");
|
||||
let out = word_wrap_line(&line, 4);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "😀😀");
|
||||
assert_eq!(concat_line(&out[1]), "😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_split_within_span_preserves_style() {
|
||||
use ratatui::style::Stylize;
|
||||
let line = Line::from(vec!["abcd".red()]);
|
||||
let out = word_wrap_line(&line, 2);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].spans.len(), 1);
|
||||
assert_eq!(out[1].spans.len(), 1);
|
||||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||||
assert_eq!(out[1].spans[0].style.fg, Some(Color::Red));
|
||||
assert_eq!(concat_line(&out[0]), "ab");
|
||||
assert_eq!(concat_line(&out[1]), "cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_applies_initial_indent_only_once() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
|
||||
let lines = vec![Line::from("hello world"), Line::from("foo bar baz")];
|
||||
let out = word_wrap_lines(&lines, opts);
|
||||
|
||||
// Expect: first line prefixed with "- ", subsequent wrapped pieces with " "
|
||||
// and for the second input line, there should be no "- " prefix on its first piece
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert!(rendered[0].starts_with("- "));
|
||||
for r in rendered.iter().skip(1) {
|
||||
assert!(r.starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_without_indents_is_concat_of_single_wraps() {
|
||||
let lines = vec![Line::from("hello"), Line::from("world!")];
|
||||
let out = word_wrap_lines(&lines, 10);
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_borrowed_applies_initial_indent_only_once() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
|
||||
let lines = [Line::from("hello world"), Line::from("foo bar baz")];
|
||||
let out = word_wrap_lines_borrowed(lines.iter().collect::<Vec<_>>(), opts);
|
||||
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert!(rendered.first().unwrap().starts_with("- "));
|
||||
for r in rendered.iter().skip(1) {
|
||||
assert!(r.starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() {
|
||||
let lines = [Line::from("hello"), Line::from("world!")];
|
||||
let out = word_wrap_lines_borrowed(lines.iter().collect::<Vec<_>>(), 10);
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_counts_double_width_emoji() {
|
||||
let line = "😀😀😀".into(); // each emoji ~ width 2
|
||||
assert_eq!(word_wrap_line(&line, 4).len(), 2);
|
||||
assert_eq!(word_wrap_line(&line, 2).len(), 3);
|
||||
assert_eq!(word_wrap_line(&line, 6).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_does_not_split_words_simple_english() {
|
||||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||||
let line = Line::from(sample);
|
||||
let lines = [line];
|
||||
// Force small width to exercise wrapping at spaces.
|
||||
let wrapped = word_wrap_lines_borrowed(&lines, 40);
|
||||
let joined: String = wrapped.iter().map(|l| l.to_string()).join("\n");
|
||||
assert_eq!(
|
||||
joined,
|
||||
r#"Years passed, and Willowmere thrived
|
||||
in peace and friendship. Mira’s herb
|
||||
garden flourished with both ordinary and
|
||||
enchanted plants, and travelers spoke
|
||||
of the kindness of the woman who tended
|
||||
them."#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,4 @@ env = { "API_KEY" = "value" }
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> It is somewhat experimental, but the Codex CLI can also be run as an MCP _server_ via `codex mcp`. If you launch it with an MCP client such as `npx @modelcontextprotocol/inspector codex mcp` and send it a `tools/list` request, you will see that there is only one tool, `codex`, that accepts a grab-bag of inputs, including a catch-all `config` map for anything you might want to override. Feel free to play around with it and provide feedback via GitHub issues.
|
||||
> It is somewhat experimental, but the Codex CLI can also be run as an MCP _server_ via `codex mcp`. If you launch it with an MCP client such as `npx @modelcontextprotocol/inspector codex mcp` and send it a `tools/list` request, you will see that there is only one tool, `codex`, that accepts a grab-bag of inputs, including a catch-all `config` map for anything you might want to override. Feel free to play around with it and provide feedback via GitHub issues.
|
||||
|
||||
@@ -182,7 +182,6 @@ Here is an example of a `config.toml` that defines multiple profiles:
|
||||
```toml
|
||||
model = "o3"
|
||||
approval_policy = "untrusted"
|
||||
disable_response_storage = false
|
||||
|
||||
# Setting `profile` is equivalent to specifying `--profile o3` on the command
|
||||
# line, though the `--profile` flag can still be used to override this value.
|
||||
@@ -209,7 +208,6 @@ model_provider = "openai-chat-completions"
|
||||
model = "o3"
|
||||
model_provider = "openai"
|
||||
approval_policy = "on-failure"
|
||||
disable_response_storage = true
|
||||
```
|
||||
|
||||
Users can specify config values at multiple levels. Order of precedence is as follows:
|
||||
@@ -336,6 +334,8 @@ Defines the list of MCP servers that Codex can consult for tool use. Currently,
|
||||
|
||||
**Note:** Codex may cache the list of tools and resources from an MCP server so that Codex can include this information in context at startup without spawning all the servers. This is designed to save resources by loading MCP servers lazily.
|
||||
|
||||
Each server may set `startup_timeout_ms` to adjust how long Codex waits for it to start and respond to a tools listing. The default is `10_000` (10 seconds).
|
||||
|
||||
This config option is comparable to how Claude and Cursor define `mcpServers` in their respective JSON config files, though because Codex uses TOML for its config language, the format is slightly different. For example, the following config in JSON:
|
||||
|
||||
```json
|
||||
@@ -360,14 +360,8 @@ Should be represented as follows in `~/.codex/config.toml`:
|
||||
command = "npx"
|
||||
args = ["-y", "mcp-server"]
|
||||
env = { "API_KEY" = "value" }
|
||||
```
|
||||
|
||||
## disable_response_storage
|
||||
|
||||
Currently, customers whose accounts are set to use Zero Data Retention (ZDR) must set `disable_response_storage` to `true` so that Codex uses an alternative to the Responses API that works with ZDR:
|
||||
|
||||
```toml
|
||||
disable_response_storage = true
|
||||
# Optional: override the default 10s startup timeout
|
||||
startup_timeout_ms = 20_000
|
||||
```
|
||||
|
||||
## shell_environment_policy
|
||||
@@ -584,6 +578,7 @@ Options that are specific to the TUI.
|
||||
| `mcp_servers.<id>.command` | string | MCP server launcher command. |
|
||||
| `mcp_servers.<id>.args` | array<string> | MCP server args. |
|
||||
| `mcp_servers.<id>.env` | map<string,string> | MCP server env vars. |
|
||||
| `mcp_servers.<id>.startup_timeout_ms` | number | Startup timeout in milliseconds (default: 10_000). Timeout is applied both for initializing MCP server and initially listing tools. |
|
||||
| `model_providers.<id>.name` | string | Display name. |
|
||||
| `model_providers.<id>.base_url` | string | API base URL. |
|
||||
| `model_providers.<id>.env_key` | string | Env var for API key. |
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
Key flags: `--model/-m`, `--ask-for-approval/-a`.
|
||||
|
||||
<!--
|
||||
Resume options:
|
||||
|
||||
- `--resume`: open an interactive picker of recent sessions (shows a preview of the first real user message). Conflicts with `--continue`.
|
||||
@@ -21,6 +22,7 @@ Examples:
|
||||
codex --resume
|
||||
codex --continue
|
||||
```
|
||||
-->
|
||||
|
||||
### Running with a prompt as input
|
||||
|
||||
|
||||
@@ -8,16 +8,23 @@ Currently, we made Codex binaries available in three places:
|
||||
|
||||
# Cutting a Release
|
||||
|
||||
Currently, choosing the version number for the next release is a manual process. In general, just go to https://github.com/openai/codex/releases/latest and see what the latest release is and increase the minor version by `1`, so if the current release is `0.20.0`, then the next release should be `0.21.0`.
|
||||
Run the `codex-rs/scripts/create_github_release` script in the repository to publish a new release. The script will choose the appropriate version number depending on the type of release you are creating.
|
||||
|
||||
Assuming you are trying to publish `0.21.0`, first you would run:
|
||||
To cut a new alpha release from `main` (feel free to cut alphas liberally):
|
||||
|
||||
```shell
|
||||
VERSION=0.21.0
|
||||
./codex-rs/scripts/create_github_release.sh "$VERSION"
|
||||
```
|
||||
./codex-rs/scripts/create_github_release --publish-alpha
|
||||
```
|
||||
|
||||
This will kick off a GitHub Action to build the release, so go to https://github.com/openai/codex/actions/workflows/rust-release.yml to find the corresponding workflow. (Note: we should automate finding the workflow URL with `gh`.)
|
||||
To cut a new _public_ release from `main` (which requires more caution), run:
|
||||
|
||||
```
|
||||
./codex-rs/scripts/create_github_release --publish-release
|
||||
```
|
||||
|
||||
TIP: Add the `--dry-run` flag to report the next version number for the respective release and exit.
|
||||
|
||||
Running the publishing script will kick off a GitHub Action to build the release, so go to https://github.com/openai/codex/actions/workflows/rust-release.yml to find the corresponding workflow. (Note: we should automate finding the workflow URL with `gh`.)
|
||||
|
||||
When the workflow finishes, the GitHub Release is "done," but you still have to consider npm and Homebrew.
|
||||
|
||||
|
||||
14
docs/zdr.md
14
docs/zdr.md
@@ -1,15 +1,3 @@
|
||||
## Zero data retention (ZDR) usage
|
||||
|
||||
Codex CLI **does** support OpenAI organizations with [Zero Data Retention (ZDR)](https://platform.openai.com/docs/guides/your-data#zero-data-retention) enabled. If your OpenAI organization has Zero Data Retention enabled and you still encounter errors such as:
|
||||
|
||||
```
|
||||
OpenAI rejected the request. Error details: Status: 400, Code: unsupported_parameter, Type: invalid_request_error, Message: 400 Previous response cannot be used for this organization due to Zero Data Retention.
|
||||
```
|
||||
|
||||
Ensure you are running `codex` with `--config disable_response_storage=true` or add this line to `~/.codex/config.toml` to avoid specifying the command line option each time:
|
||||
|
||||
```toml
|
||||
disable_response_storage = true
|
||||
```
|
||||
|
||||
See [the configuration documentation on `disable_response_storage`](./config.md#disable_response_storage) for details.
|
||||
Codex CLI natively supports OpenAI organizations with [Zero Data Retention (ZDR)](https://platform.openai.com/docs/guides/your-data#zero-data-retention) enabled.
|
||||
|
||||
Reference in New Issue
Block a user