From 323a5cb7e70cc9ae815dc43edb4d2292437483b3 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Fri, 5 Sep 2025 07:02:11 -0700
Subject: [PATCH 01/30] refactor: remove AttachImage tui event (#3191)
TuiEvent is supposed to be purely events that come from the "driver",
i.e. events from the terminal. Everything app-specific should be an
AppEvent. In this case, it didn't need to be an event at all.
---
codex-rs/tui/src/app.rs | 9 ---------
codex-rs/tui/src/chatwidget.rs | 12 ++++++++++++
codex-rs/tui/src/tui.rs | 34 ----------------------------------
3 files changed, 12 insertions(+), 43 deletions(-)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 40a7f20886..12cd789c5f 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -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)
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 7acb802c73..65ecbf749f 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -57,6 +57,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;
@@ -745,6 +746,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();
}
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
index df957fee87..26c188668d 100644
--- a/codex-rs/tui/src/tui.rs
+++ b/codex-rs/tui/src/tui.rs
@@ -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!(
From d6182becbe155776b159270fe4a5e88d607f0c5e Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Fri, 5 Sep 2025 07:10:32 -0700
Subject: [PATCH 02/30] syntax-highlight bash lines (#3142)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
i'm not yet convinced i have the best heuristics for what to highlight,
but this feels like a useful step towards something a bit easier to
read, esp. when the model is producing large commands.
also a fairly significant refactor of our line wrapping logic.
---
AGENTS.md | 14 +-
codex-rs/core/src/lib.rs | 2 +-
codex-rs/tui/src/backtrack_helpers.rs | 2 +-
codex-rs/tui/src/bottom_pane/textarea.rs | 19 +-
...et__tests__binary_size_ideal_response.snap | 19 +-
codex-rs/tui/src/history_cell.rs | 141 ++---
codex-rs/tui/src/insert_history.rs | 166 +-----
codex-rs/tui/src/lib.rs | 1 +
codex-rs/tui/src/pager_overlay.rs | 6 +-
codex-rs/tui/src/render/highlight.rs | 145 +++++
codex-rs/tui/src/render/mod.rs | 1 +
...both_lines_wrap_with_correct_prefixes.snap | 9 +-
...with_extra_indent_on_subsequent_lines.snap | 1 +
...nd_wraps_with_four_space_continuation.snap | 8 +-
codex-rs/tui/src/status_indicator_widget.rs | 12 +-
codex-rs/tui/src/wrapping.rs | 545 ++++++++++++++++++
16 files changed, 786 insertions(+), 305 deletions(-)
create mode 100644 codex-rs/tui/src/render/highlight.rs
create mode 100644 codex-rs/tui/src/wrapping.rs
diff --git a/AGENTS.md b/AGENTS.md
index 83fd2a3ec4..697de7009f 100644
--- a/AGENTS.md
+++ b/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.
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 358c58de0c..6d26ad2e8a 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -7,7 +7,7 @@
mod apply_patch;
pub mod auth;
-mod bash;
+pub mod bash;
mod chat_completions;
mod client;
mod client_common;
diff --git a/codex-rs/tui/src/backtrack_helpers.rs b/codex-rs/tui/src/backtrack_helpers.rs
index b006ae38eb..e2306b80b1 100644
--- a/codex-rs/tui/src/backtrack_helpers.rs
+++ b/codex-rs/tui/src/backtrack_helpers.rs
@@ -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.
diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs
index cb6ef501a1..37aa85ff18 100644
--- a/codex-rs/tui/src/bottom_pane/textarea.rs
+++ b/codex-rs/tui/src/bottom_pane/textarea.rs
@@ -832,25 +832,10 @@ impl TextArea {
None => true,
};
if needs_recalc {
- let mut lines: Vec> = 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 });
}
}
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
index 5d6d5cb455..0b6ec3b392 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap
@@ -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.
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index ce048f4875..01583958aa 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -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;
@@ -97,8 +102,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 +136,16 @@ impl AgentMessageCell {
impl HistoryCell for AgentMessageCell {
fn display_lines(&self, width: u16) -> Vec> {
- let mut out: Vec> = 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> {
@@ -278,13 +270,13 @@ impl ExecCell {
}
fn exploring_display_lines(&self, width: u16) -> Vec> {
- let mut lines: Vec> = Vec::new();
+ let mut out: Vec> = 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 +291,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 +364,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> {
use textwrap::Options as TwOptions;
- use textwrap::WordSplitter;
let mut lines: Vec> = Vec::new();
let [call] = &self.calls.as_slice() else {
@@ -424,38 +401,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> = 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 +433,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
}
}
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
index 1ecbd3448b..bffd3f2b40 100644
--- a/codex-rs/tui/src/insert_history.rs
+++ b/codex-rs/tui/src/insert_history.rs
@@ -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(
// 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(
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- >,
+ I: IntoIterator
- >,
{
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>
-where
- I: IntoIterator
- >,
-{
- 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> {
- 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> = 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> = 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::()
- })
- .collect::>()
- .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}"
- );
- }
}
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index c52f48e62f..c43572dd81 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -58,6 +58,7 @@ mod streaming;
mod text_formatting;
mod tui;
mod user_approval_widget;
+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.
diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs
index 70365aed1d..0a7e54f06f 100644
--- a/codex-rs/tui/src/pager_overlay.rs
+++ b/codex-rs/tui/src/pager_overlay.rs
@@ -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> = Vec::new();
let mut src_idx: Vec = 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,
diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs
new file mode 100644
index 0000000000..393aa33705
--- /dev/null
+++ b/codex-rs/tui/src/render/highlight.rs
@@ -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> {
+ // Parse once; use the tree for both highlighting and heredoc body detection.
+ let spans: Vec> = 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> = 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> = 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::()
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(reconstructed, s);
+
+ fn is_dim(span: &Span<'_>) -> bool {
+ span.style.add_modifier.contains(Modifier::DIM)
+ }
+ let dimmed: Vec = 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::()
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(reconstructed, s);
+
+ fn is_dim(span: &Span<'_>) -> bool {
+ span.style.add_modifier.contains(Modifier::DIM)
+ }
+ let dimmed: Vec = 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()));
+ }
+}
diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs
index 71ae62dba1..ed1e215e65 100644
--- a/codex-rs/tui/src/render/mod.rs
+++ b/codex-rs/tui/src/render/mod.rs
@@ -1,2 +1,3 @@
+pub mod highlight;
pub mod line_utils;
pub mod markdown_utils;
diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap
index 99d6579fe4..0964b64aec 100644
--- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap
+++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap
@@ -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
diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap
index 4e383a5aa2..de36cd92af 100644
--- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap
+++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap
@@ -1,5 +1,6 @@
---
source: tui/src/history_cell.rs
+assertion_line: 1797
expression: rendered
---
• Ran
diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap
index 548d50cea2..5ca1330757 100644
--- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap
+++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap
@@ -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
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index 07ecba0c2b..8f492c062b 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -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").
@@ -54,11 +52,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 {
@@ -157,11 +152,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}");
diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs
new file mode 100644
index 0000000000..508c6a45f4
--- /dev/null
+++ b/codex-rs/tui/src/wrapping.rs
@@ -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>
+where
+ O: Into>,
+{
+ let opts = width_or_options.into();
+ let mut lines: Vec> = 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>
+where
+ O: Into>,
+{
+ let opts = width_or_options.into();
+ let mut lines: Vec> = 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 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>
+where
+ O: Into>,
+{
+ // 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> = 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>
+where
+ I: IntoIterator
- >,
+ O: Into>,
+{
+ let base_opts: RtOptions<'a> = width_or_options.into();
+ let mut out: Vec> = 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>
+where
+ I: IntoIterator
- >,
+ O: Into>,
+{
+ let base_opts: RtOptions<'a> = width_or_options.into();
+ let mut out: Vec> = 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, ratatui::style::Style)],
+ range: &Range,
+) -> Line<'a> {
+ let start_byte = range.start;
+ let end_byte = range.end;
+ let mut acc: Vec> = 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::()
+ }
+
+ #[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 = 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 = 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::>(), opts);
+
+ let rendered: Vec = 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::>(), 10);
+ let rendered: Vec = 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."#
+ );
+ }
+}
From db3834733acb46c83922573e4a161d6553346a42 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Fri, 5 Sep 2025 09:10:41 -0700
Subject: [PATCH 03/30] [BREAKING] Stop loading project .env files (#3184)
Loading project local .env often loads settings that break codex cli.
Fixes: https://github.com/openai/codex/issues/3174
---
codex-rs/arg0/src/lib.rs | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs
index fc66f978a5..d5df68e554 100644
--- a/codex-rs/arg0/src/lib.rs
+++ b/codex-rs/arg0/src/lib.rs
@@ -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.
From ba631e79281bd57dbfd53966ceb0273dc265b4e6 Mon Sep 17 00:00:00 2001
From: jif-oai
Date: Fri, 5 Sep 2025 09:51:01 -0700
Subject: [PATCH 04/30] ZSH on UNIX system and better detection (#3187)
---
codex-rs/core/src/shell.rs | 233 +++++++++++++++++++++++++++++--------
1 file changed, 185 insertions(+), 48 deletions(-)
diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs
index 3a69874eb2..7ef79076e0 100644
--- a/codex-rs/core/src/shell.rs
+++ b/codex-rs/core/src/shell.rs
@@ -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) -> Option> {
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,
+ shell_path: &str,
+ rc_path: &str,
+) -> Option> {
+ 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) -> Option {
match command.as_slice() {
// exactly three items
@@ -116,44 +131,43 @@ fn strip_bash_lc(command: &Vec) -> Option {
}
}
-#[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";
From 5775174ec2266b81d53f8e3f8af064c2f846b958 Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Fri, 5 Sep 2025 10:41:47 -0700
Subject: [PATCH 05/30] Never store requests (#3212)
When item ids are sent to Responses API it will load them from the
database ignoring the provided values. This adds extra latency.
Not having the mode to store requests also allows us to simplify the
code.
## Breaking change
The `disable_response_storage` configuration option is removed.
---
codex-rs/core/src/client.rs | 14 ++---------
codex-rs/core/src/client_common.rs | 8 ++----
codex-rs/core/src/codex.rs | 10 --------
codex-rs/core/src/config.rs | 23 -----------------
codex-rs/core/src/config_profile.rs | 2 --
codex-rs/core/tests/suite/client.rs | 25 -------------------
codex-rs/core/tests/suite/prompt_caching.rs | 7 ------
codex-rs/exec/src/lib.rs | 1 -
.../mcp-server/src/codex_message_processor.rs | 1 -
codex-rs/mcp-server/src/codex_tool_config.rs | 1 -
codex-rs/mcp-server/tests/suite/config.rs | 2 --
codex-rs/protocol/src/mcp_protocol.rs | 1 -
codex-rs/protocol/src/models.rs | 8 ++++--
codex-rs/tui/src/lib.rs | 1 -
docs/advanced.md | 2 +-
docs/config.md | 10 --------
docs/zdr.md | 14 +----------
17 files changed, 12 insertions(+), 118 deletions(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 0481767030..48e42ee034 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -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 = if !store && reasoning.is_some() {
+ let include: Vec = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
vec![]
@@ -204,7 +194,7 @@ 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()),
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index d561d36946..2f849239ab 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -25,9 +25,6 @@ pub struct Prompt {
/// Conversation context input items.
pub input: Vec,
- /// 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,
@@ -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,
- /// true when using the Responses API.
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec,
@@ -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,
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 2028c4240e..172d4e0ff7 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -184,7 +184,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(),
};
@@ -301,7 +300,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 +332,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
@@ -370,7 +366,6 @@ impl Session {
base_instructions,
approval_policy,
sandbox_policy,
- disable_response_storage,
notify,
cwd,
} = configure_session;
@@ -462,7 +457,6 @@ impl Session {
sandbox_policy,
shell_environment_policy: config.shell_environment_policy.clone(),
cwd,
- disable_response_storage,
};
let sess = Arc::new(Session {
session_id,
@@ -1117,7 +1111,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.
@@ -1199,7 +1192,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
@@ -1604,7 +1596,6 @@ async fn run_turn(
let prompt = Prompt {
input,
- store: !turn_context.disable_response_storage,
tools,
base_instructions_override: turn_context.base_instructions.clone(),
};
@@ -1858,7 +1849,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()),
};
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index e022ba0188..7fb4a0ddd9 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -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,
@@ -417,11 +412,6 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option,
- /// 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,
-
/// Optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option>,
@@ -640,7 +630,6 @@ pub struct ConfigOverrides {
pub include_plan_tool: Option,
pub include_apply_patch_tool: Option,
pub include_view_image_tool: Option,
- pub disable_response_storage: Option,
pub show_raw_agent_reasoning: Option,
pub tools_web_search_request: Option,
}
@@ -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;
@@ -802,11 +790,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 +1054,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 +1083,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 +1180,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 +1237,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 +1309,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 +1367,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(),
diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs
index d6ab03f60f..b98f78d311 100644
--- a/codex-rs/core/src/config_profile.rs
+++ b/codex-rs/core/src/config_profile.rs
@@ -15,7 +15,6 @@ pub struct ConfigProfile {
/// [`ModelProviderInfo`] to use.
pub model_provider: Option,
pub approval_policy: Option,
- pub disable_response_storage: Option,
pub model_reasoning_effort: Option,
pub model_reasoning_summary: Option,
pub model_verbosity: Option,
@@ -29,7 +28,6 @@ impl From 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,
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index 1bf7ef0c00..cc1369e76f 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -224,19 +224,16 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
let expected_input = serde_json::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" }]
}
@@ -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::().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::().unwrap();
- assert!(
- request_body["store"].as_bool().unwrap(),
- "store should be true for API key auth"
- );
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -990,31 +970,26 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
let r3_tail_expected = serde_json::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"}]
}
diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs
index 11c6ea9acf..4625841a64 100644
--- a/codex-rs/core/tests/suite/prompt_caching.rs
+++ b/codex-rs/core/tests/suite/prompt_caching.rs
@@ -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() {
"#;
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" } ]
});
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index d5991efdad..8215d7e698 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -149,7 +149,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> 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,
};
diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs
index 2309e01dcd..828cf1f167 100644
--- a/codex-rs/mcp-server/src/codex_message_processor.rs
+++ b/codex-rs/mcp-server/src/codex_message_processor.rs
@@ -994,7 +994,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,
};
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
index c29cb52c22..0ba5593fcb 100644
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
@@ -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,
};
diff --git a/codex-rs/mcp-server/tests/suite/config.rs b/codex-rs/mcp-server/tests/suite/config.rs
index 809d426b24..bc8789ce78 100644
--- a/codex-rs/mcp-server/tests/suite/config.rs
+++ b/codex-rs/mcp-server/tests/suite/config.rs
@@ -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()),
},
)]),
diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs
index 3980de0ae1..26d39eb6a8 100644
--- a/codex-rs/protocol/src/mcp_protocol.rs
+++ b/codex-rs/protocol/src/mcp_protocol.rs
@@ -371,7 +371,6 @@ pub struct Profile {
/// [`ModelProviderInfo`] to use.
pub model_provider: Option,
pub approval_policy: Option,
- pub disable_response_storage: Option,
pub model_reasoning_effort: Option,
pub model_reasoning_summary: Option,
pub model_verbosity: Option,
diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs
index 71b517092a..e8dbf19c98 100644
--- a/codex-rs/protocol/src/models.rs
+++ b/codex-rs/protocol/src/models.rs
@@ -42,11 +42,13 @@ pub enum ContentItem {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
Message {
+ #[serde(skip_serializing)]
id: Option,
role: String,
content: Vec,
},
Reasoning {
+ #[serde(default)]
id: String,
summary: Vec,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
@@ -55,6 +57,7 @@ pub enum ResponseItem {
},
LocalShellCall {
/// Set when using the chat completions API.
+ #[serde(skip_serializing)]
id: Option,
/// Set when using the Responses API.
call_id: Option,
@@ -62,6 +65,7 @@ pub enum ResponseItem {
action: LocalShellAction,
},
FunctionCall {
+ #[serde(skip_serializing)]
id: Option,
name: String,
// The Responses API returns the function call arguments as a *string* that contains
@@ -82,7 +86,7 @@ pub enum ResponseItem {
output: FunctionCallOutputPayload,
},
CustomToolCall {
- #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[serde(skip_serializing)]
id: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option,
@@ -104,7 +108,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,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option,
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index c43572dd81..76ca93a368 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -130,7 +130,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),
};
diff --git a/docs/advanced.md b/docs/advanced.md
index 26f735991f..42bbcc338f 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -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.
\ No newline at end of file
+> 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.
diff --git a/docs/config.md b/docs/config.md
index 285c2ef840..d17eab477d 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -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:
@@ -362,14 +360,6 @@ 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
-```
-
## shell_environment_policy
Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it now passes **your full environment** to those subprocesses. You can tune this behavior via the **`shell_environment_policy`** block in `config.toml`:
diff --git a/docs/zdr.md b/docs/zdr.md
index 92e78a34b6..d030e8d07f 100644
--- a/docs/zdr.md
+++ b/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.
\ No newline at end of file
+Codex CLI natively supports OpenAI organizations with [Zero Data Retention (ZDR)](https://platform.openai.com/docs/guides/your-data#zero-data-retention) enabled.
From c11696f6b12b65cc240d426b996a48b5f48dd95a Mon Sep 17 00:00:00 2001
From: Ahmed Ibrahim
Date: Fri, 5 Sep 2025 13:12:46 -0700
Subject: [PATCH 06/30] hide resume until it's complete (#3218)
Hide resume functionality until it's fully done.
---
codex-rs/tui/src/cli.rs | 10 ++++++++--
docs/getting-started.md | 2 ++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs
index f419274af0..cb5f8ac778 100644
--- a/codex-rs/tui/src/cli.rs
+++ b/codex-rs/tui/src/cli.rs
@@ -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,
diff --git a/docs/getting-started.md b/docs/getting-started.md
index cb9fc736ac..3f7473fd33 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -10,6 +10,7 @@
Key flags: `--model/-m`, `--ask-for-approval/-a`.
+
### Running with a prompt as input
From 17a80d43c8093bb4cf22523cb6671b5b344018df Mon Sep 17 00:00:00 2001
From: Eric Traut
Date: Fri, 5 Sep 2025 14:29:00 -0700
Subject: [PATCH 07/30] Added logic to cancel pending oauth login to free up
localhost port (#3217)
This PR addresses an issue that several users have reported. If the
local oauth login server in one codex instance is left running (e.g. the
user abandons the oauth flow), a subsequent codex instance will receive
an error when attempting to log in because the localhost port is already
in use by the dangling web server from the first instance.
This PR adds a cancelation mechanism that the second instance can use to
abort the first login attempt and free up the port.
---
codex-rs/login/src/server.rs | 105 ++++++++++++++++--
.../login/tests/suite/login_server_e2e.rs | 66 +++++++++++
2 files changed, 161 insertions(+), 10 deletions(-)
diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs
index bca3b16420..cfd1db8b4a 100644
--- a/codex-rs/login/src/server.rs
+++ b/codex-rs/login/src/server.rs
@@ -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 {
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 {
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 {
enum HandledRequest {
Response(Response>>),
RedirectWithHeader(Header),
- ResponseAndExit(Response>>),
+ ResponseAndExit {
+ response: Response>>,
+ 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 {
+ 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::()
+ .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,
diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs
index ef6b80fbe1..5a600a4bed 100644
--- a/codex-rs/login/tests/suite/login_server_e2e.rs
+++ b/codex-rs/login/tests/suite/login_server_e2e.rs
@@ -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");
+}
From 6cfc012e9dfe4fbe4a413420bde6e96895d31770 Mon Sep 17 00:00:00 2001
From: Enrique Moreno Tent
Date: Sat, 6 Sep 2025 00:06:36 +0200
Subject: [PATCH 08/30] feat(tui): show minutes/hours in thinking timer (#3220)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
What
- Show compact elapsed time in the TUI status indicator: Xs, MmSSs,
HhMMmSSs.
- Add private helper fmt_elapsed_compact with a unit test.
Why
- Seconds‑only becomes hard to read during longer runs; minutes/hours
improve clarity without extra noise.
How
- Implemented in codex-rs/tui/src/status_indicator_widget.rs only.
- The helper is used when rendering the existing “Working/Thinking”
timer.
- No changes to codex-common::elapsed::format_duration or other crates.
Scope/Impact
- TUI‑only; no public API changes; minimal risk.
- Snapshot tests should remain unchanged (most show “0s”).
Before/After
- Working (65s • Esc to interrupt) → Working (1m05s • Esc to interrupt)
- Working (3723s • …) → Working (1h02m03s • …)
Tests
- Unit: fmt_elapsed_compact_formats_seconds_minutes_hours.
- Local checks: cargo fmt --all, cargo clippy -p codex-tui -- -D
warnings, cargo test -p codex-tui.
Notes
- Open to adjusting the exact format or moving the helper if maintainers
prefer a shared location.
Signed-off-by: Enrique Moreno Tent
---
codex-rs/tui/src/status_indicator_widget.rs | 36 ++++++++++++++++++++-
1 file changed, 35 insertions(+), 1 deletion(-)
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
index 8f492c062b..5b2fe720c3 100644
--- a/codex-rs/tui/src/status_indicator_widget.rs
+++ b/codex-rs/tui/src/status_indicator_widget.rs
@@ -31,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 {
@@ -136,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(),
]);
@@ -184,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::();
From 45c3b20041961102b1a48b5c66d50af4b8b20da2 Mon Sep 17 00:00:00 2001
From: Eric Traut
Date: Fri, 5 Sep 2025 16:27:31 -0700
Subject: [PATCH 09/30] Added CLI version to `/status` output (#3223)
This PR adds the CLI version to the `/status` output.
This addresses feature request #2767
---
codex-rs/tui/src/history_cell.rs | 6 ++++++
codex-rs/tui/src/lib.rs | 1 +
codex-rs/tui/src/updates.rs | 5 +++--
codex-rs/tui/src/version.rs | 2 ++
4 files changed, 12 insertions(+), 2 deletions(-)
create mode 100644 codex-rs/tui/src/version.rs
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 01583958aa..7e1cf25e92 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -950,6 +950,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 {
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index 76ca93a368..dc5569bfe5 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -58,6 +58,7 @@ 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
diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs
index 39d084b407..90ef6d8580 100644
--- a/codex-rs/tui/src/updates.rs
+++ b/codex-rs/tui/src/updates.rs
@@ -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 {
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 {
}
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
diff --git a/codex-rs/tui/src/version.rs b/codex-rs/tui/src/version.rs
new file mode 100644
index 0000000000..8c8d108dc6
--- /dev/null
+++ b/codex-rs/tui/src/version.rs
@@ -0,0 +1,2 @@
+/// The current Codex CLI version as embedded at compile time.
+pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
From ba9620aea708c924b6924f142f7f808e2ec9333c Mon Sep 17 00:00:00 2001
From: Anton Panasenko
Date: Fri, 5 Sep 2025 16:56:58 -0700
Subject: [PATCH 10/30] [codex] respect overrides for model family
configuration from toml file (#3176)
---
codex-rs/core/src/config.rs | 31 ++++++++++++++++---------------
1 file changed, 16 insertions(+), 15 deletions(-)
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 7fb4a0ddd9..1512759d87 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -732,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
From bd65f81e54168262d75f83b28665f465de5901cd Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Fri, 5 Sep 2025 21:54:18 -0700
Subject: [PATCH 11/30] chore: rewrite
codex-rs/scripts/create_github_release.sh in Python (#3226)
Migrating to Python to make this script easier to iterate on.
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3226).
* #3231
* #3230
* #3228
* __->__ #3226
---
codex-rs/scripts/create_github_release | 130 ++++++++++++++++++++++
codex-rs/scripts/create_github_release.sh | 64 -----------
2 files changed, 130 insertions(+), 64 deletions(-)
create mode 100755 codex-rs/scripts/create_github_release
delete mode 100755 codex-rs/scripts/create_github_release.sh
diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release
new file mode 100755
index 0000000000..4bbfcde768
--- /dev/null
+++ b/codex-rs/scripts/create_github_release
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+CARGO_TOML = ROOT_DIR / "Cargo.toml"
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Create a tagged Codex release.")
+ parser.add_argument(
+ "version",
+ help="Version string used for Cargo.toml and the Git tag (e.g. 0.1.0-alpha.4).",
+ )
+ return parser.parse_args(argv[1:])
+
+
+def main(argv: list[str]) -> int:
+ os.chdir(ROOT_DIR)
+ args = parse_args(argv)
+ try:
+ ensure_clean_worktree()
+ branch = current_branch()
+ ensure_on_main(branch)
+ ensure_on_origin_main()
+ create_release(args.version, branch)
+ except ReleaseError as error:
+ print(f"ERROR: {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+def ensure_clean_worktree() -> None:
+ commands = [
+ ["diff", "--quiet"],
+ ["diff", "--cached", "--quiet"],
+ ]
+ for command in commands:
+ result = run_git(command, check=False)
+ if result.returncode != 0:
+ raise ReleaseError("You have uncommitted changes.")
+
+ untracked = run_git(["ls-files", "--others", "--exclude-standard"], capture_output=True)
+ if untracked.stdout.strip():
+ raise ReleaseError("You have untracked files.")
+
+
+def ensure_on_main(branch: str) -> None:
+ if branch != "main":
+ raise ReleaseError(
+ f"Releases must be created from the 'main' branch (current: '{branch}')."
+ )
+
+
+def ensure_on_origin_main() -> None:
+ try:
+ run_git(["fetch", "--quiet", "origin", "main"])
+ except ReleaseError as error:
+ raise ReleaseError(
+ "Failed to fetch 'origin/main'. Ensure the 'origin' remote is configured and reachable."
+ ) from error
+
+ result = run_git(["merge-base", "--is-ancestor", "HEAD", "origin/main"], check=False)
+ if result.returncode != 0:
+ raise ReleaseError(
+ "Your local 'main' HEAD commit is not present on 'origin/main'. "
+ "Please push first (git push origin main) or check out a commit on 'origin/main'."
+ )
+
+
+def current_branch() -> str:
+ result = run_git(["symbolic-ref", "--short", "-q", "HEAD"], capture_output=True, check=False)
+ branch = result.stdout.strip()
+ if result.returncode != 0 or not branch:
+ raise ReleaseError("Could not determine the current branch (detached HEAD?).")
+ return branch
+
+
+def update_version(version: str) -> None:
+ content = CARGO_TOML.read_text(encoding="utf-8")
+ new_content, matches = re.subn(
+ r'^version = "[^"]+"', f'version = "{version}"', content, count=1, flags=re.MULTILINE
+ )
+ if matches != 1:
+ raise ReleaseError("Unable to update version in Cargo.toml.")
+ CARGO_TOML.write_text(new_content, encoding="utf-8")
+
+
+def create_release(version: str, branch: str) -> None:
+ tag = f"rust-v{version}"
+ run_git(["checkout", "-b", tag])
+ try:
+ update_version(version)
+ run_git(["add", "Cargo.toml"])
+ run_git(["commit", "-m", f"Release {version}"])
+ run_git(["tag", "-a", tag, "-m", f"Release {version}"])
+ run_git(["push", "origin", f"refs/tags/{tag}"])
+ finally:
+ run_git(["checkout", branch])
+
+
+class ReleaseError(RuntimeError):
+ pass
+
+
+def run_git(
+ args: list[str], *, capture_output: bool = False, check: bool = True
+) -> subprocess.CompletedProcess:
+ result = subprocess.run(
+ ["git", *args],
+ cwd=ROOT_DIR,
+ text=True,
+ capture_output=capture_output,
+ )
+ if check and result.returncode != 0:
+ stderr = result.stderr.strip() if result.stderr else ""
+ stdout = result.stdout.strip() if result.stdout else ""
+ message = stderr if stderr else stdout
+ raise ReleaseError(message or f"git {' '.join(args)} failed")
+ return result
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/codex-rs/scripts/create_github_release.sh b/codex-rs/scripts/create_github_release.sh
deleted file mode 100755
index 4bc4e25125..0000000000
--- a/codex-rs/scripts/create_github_release.sh
+++ /dev/null
@@ -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"
From 066c6cce022bf89e5ea54dc248ec86f6301cf386 Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Fri, 5 Sep 2025 21:57:11 -0700
Subject: [PATCH 12/30] chore: change create_github_release to create a fresh
clone in a temp directory (#3228)
Ran:
```
./codex-rs/scripts/create_github_release 0.31.0-alpha.1
```
which appeared to work as expected:
- workflow https://github.com/openai/codex/actions/runs/17508403922
- release
https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.1
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3228).
* #3231
* #3230
* __->__ #3228
* #3226
---
codex-rs/scripts/create_github_release | 103 ++++++++++---------------
1 file changed, 39 insertions(+), 64 deletions(-)
diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release
index 4bbfcde768..59a2adcd18 100755
--- a/codex-rs/scripts/create_github_release
+++ b/codex-rs/scripts/create_github_release
@@ -1,17 +1,13 @@
#!/usr/bin/env python3
import argparse
-import os
import re
import subprocess
import sys
+import tempfile
from pathlib import Path
-ROOT_DIR = Path(__file__).resolve().parent.parent
-CARGO_TOML = ROOT_DIR / "Cargo.toml"
-
-
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create a tagged Codex release.")
parser.add_argument(
@@ -22,87 +18,62 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def main(argv: list[str]) -> int:
- os.chdir(ROOT_DIR)
args = parse_args(argv)
try:
- ensure_clean_worktree()
- branch = current_branch()
- ensure_on_main(branch)
- ensure_on_origin_main()
- create_release(args.version, branch)
+ with tempfile.TemporaryDirectory() as temp_dir:
+ repo_dir = Path(temp_dir) / "codex"
+ clone_repository(repo_dir)
+ branch = current_branch(repo_dir)
+ create_release(args.version, branch, repo_dir)
except ReleaseError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
return 0
-def ensure_clean_worktree() -> None:
- commands = [
- ["diff", "--quiet"],
- ["diff", "--cached", "--quiet"],
- ]
- for command in commands:
- result = run_git(command, check=False)
- if result.returncode != 0:
- raise ReleaseError("You have uncommitted changes.")
-
- untracked = run_git(["ls-files", "--others", "--exclude-standard"], capture_output=True)
- if untracked.stdout.strip():
- raise ReleaseError("You have untracked files.")
-
-
-def ensure_on_main(branch: str) -> None:
- if branch != "main":
- raise ReleaseError(
- f"Releases must be created from the 'main' branch (current: '{branch}')."
- )
-
-
-def ensure_on_origin_main() -> None:
- try:
- run_git(["fetch", "--quiet", "origin", "main"])
- except ReleaseError as error:
- raise ReleaseError(
- "Failed to fetch 'origin/main'. Ensure the 'origin' remote is configured and reachable."
- ) from error
-
- result = run_git(["merge-base", "--is-ancestor", "HEAD", "origin/main"], check=False)
- if result.returncode != 0:
- raise ReleaseError(
- "Your local 'main' HEAD commit is not present on 'origin/main'. "
- "Please push first (git push origin main) or check out a commit on 'origin/main'."
- )
-
-
-def current_branch() -> str:
- result = run_git(["symbolic-ref", "--short", "-q", "HEAD"], capture_output=True, check=False)
+def current_branch(repo_dir: Path) -> str:
+ result = run_git(
+ repo_dir,
+ ["symbolic-ref", "--short", "-q", "HEAD"],
+ capture_output=True,
+ check=False,
+ )
branch = result.stdout.strip()
if result.returncode != 0 or not branch:
raise ReleaseError("Could not determine the current branch (detached HEAD?).")
return branch
-def update_version(version: str) -> None:
- content = CARGO_TOML.read_text(encoding="utf-8")
+def update_version(version: str, cargo_toml: Path) -> None:
+ content = cargo_toml.read_text(encoding="utf-8")
new_content, matches = re.subn(
r'^version = "[^"]+"', f'version = "{version}"', content, count=1, flags=re.MULTILINE
)
if matches != 1:
raise ReleaseError("Unable to update version in Cargo.toml.")
- CARGO_TOML.write_text(new_content, encoding="utf-8")
+ cargo_toml.write_text(new_content, encoding="utf-8")
-def create_release(version: str, branch: str) -> None:
+def create_release(version: str, branch: str, repo_dir: Path) -> None:
tag = f"rust-v{version}"
- run_git(["checkout", "-b", tag])
+ run_git(repo_dir, ["checkout", "-b", tag])
try:
- update_version(version)
- run_git(["add", "Cargo.toml"])
- run_git(["commit", "-m", f"Release {version}"])
- run_git(["tag", "-a", tag, "-m", f"Release {version}"])
- run_git(["push", "origin", f"refs/tags/{tag}"])
+ update_version(version, repo_dir / "codex-rs" / "Cargo.toml")
+ run_git(repo_dir, ["add", "codex-rs/Cargo.toml"])
+ run_git(repo_dir, ["commit", "-m", f"Release {version}"])
+ run_git(repo_dir, ["tag", "-a", tag, "-m", f"Release {version}"])
+ run_git(repo_dir, ["push", "origin", f"refs/tags/{tag}"])
finally:
- run_git(["checkout", branch])
+ run_git(repo_dir, ["checkout", branch])
+
+
+def clone_repository(destination: Path) -> None:
+ result = subprocess.run(
+ ["gh", "repo", "clone", "openai/codex", str(destination), "--", "--depth", "1"],
+ text=True,
+ )
+ if result.returncode != 0:
+ raise ReleaseError("Failed to clone openai/codex using gh.")
class ReleaseError(RuntimeError):
@@ -110,11 +81,15 @@ class ReleaseError(RuntimeError):
def run_git(
- args: list[str], *, capture_output: bool = False, check: bool = True
+ repo_dir: Path,
+ args: list[str],
+ *,
+ capture_output: bool = False,
+ check: bool = True,
) -> subprocess.CompletedProcess:
result = subprocess.run(
["git", *args],
- cwd=ROOT_DIR,
+ cwd=repo_dir,
text=True,
capture_output=capture_output,
)
From b1d5f7c0bd7dbe6eda9d0890adddce5bf4ffb2f4 Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Fri, 5 Sep 2025 21:58:42 -0700
Subject: [PATCH 13/30] chore: use gh instead of git to do work to avoid
overhead of a local clone (#3230)
The advantage of this implementation is that it can be run from
"anywhere" so long as the user has `gh` installed with the appropriate
credentials to write to the `openai/codex` repo. Unlike the previous
implementation, it avoids the overhead of creating a local clone of the
repo.
Ran:
```
./codex-rs/scripts/create_github_release 0.31.0-alpha.2
```
which appeared to work as expected:
- workflow https://github.com/openai/codex/actions/runs/17508564352
- release
https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.2
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3230).
* #3231
* __->__ #3230
* #3228
* #3226
---
codex-rs/scripts/create_github_release | 243 ++++++++++++++++++-------
1 file changed, 173 insertions(+), 70 deletions(-)
diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release
index 59a2adcd18..08f0b11fc7 100755
--- a/codex-rs/scripts/create_github_release
+++ b/codex-rs/scripts/create_github_release
@@ -1,11 +1,16 @@
#!/usr/bin/env python3
import argparse
+import base64
+import json
import re
import subprocess
import sys
-import tempfile
-from pathlib import Path
+
+
+REPO = "openai/codex"
+BRANCH_REF = "heads/main"
+CARGO_TOML_PATH = "codex-rs/Cargo.toml"
def parse_args(argv: list[str]) -> argparse.Namespace:
@@ -20,85 +25,183 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
- with tempfile.TemporaryDirectory() as temp_dir:
- repo_dir = Path(temp_dir) / "codex"
- clone_repository(repo_dir)
- branch = current_branch(repo_dir)
- create_release(args.version, branch, repo_dir)
+ 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, args.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(args.version, tree_sha, base_commit)
+ print(f"Commit SHA: {commit_sha}")
+ print("Creating tag...")
+ tag_sha = create_tag(args.version, commit_sha)
+ print(f"Tag SHA: {tag_sha}")
+ print("Creating tag ref...")
+ create_tag_ref(args.version, tag_sha)
+ print("Done.")
except ReleaseError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
return 0
-def current_branch(repo_dir: Path) -> str:
- result = run_git(
- repo_dir,
- ["symbolic-ref", "--short", "-q", "HEAD"],
- capture_output=True,
- check=False,
- )
- branch = result.stdout.strip()
- if result.returncode != 0 or not branch:
- raise ReleaseError("Could not determine the current branch (detached HEAD?).")
- return branch
-
-
-def update_version(version: str, cargo_toml: Path) -> None:
- content = cargo_toml.read_text(encoding="utf-8")
- new_content, matches = re.subn(
- r'^version = "[^"]+"', f'version = "{version}"', content, count=1, flags=re.MULTILINE
- )
- if matches != 1:
- raise ReleaseError("Unable to update version in Cargo.toml.")
- cargo_toml.write_text(new_content, encoding="utf-8")
-
-
-def create_release(version: str, branch: str, repo_dir: Path) -> None:
- tag = f"rust-v{version}"
- run_git(repo_dir, ["checkout", "-b", tag])
- try:
- update_version(version, repo_dir / "codex-rs" / "Cargo.toml")
- run_git(repo_dir, ["add", "codex-rs/Cargo.toml"])
- run_git(repo_dir, ["commit", "-m", f"Release {version}"])
- run_git(repo_dir, ["tag", "-a", tag, "-m", f"Release {version}"])
- run_git(repo_dir, ["push", "origin", f"refs/tags/{tag}"])
- finally:
- run_git(repo_dir, ["checkout", branch])
-
-
-def clone_repository(destination: Path) -> None:
- result = subprocess.run(
- ["gh", "repo", "clone", "openai/codex", str(destination), "--", "--depth", "1"],
- text=True,
- )
- if result.returncode != 0:
- raise ReleaseError("Failed to clone openai/codex using gh.")
-
-
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 run_git(
- repo_dir: Path,
- args: list[str],
- *,
- capture_output: bool = False,
- check: bool = True,
-) -> subprocess.CompletedProcess:
- result = subprocess.run(
- ["git", *args],
- cwd=repo_dir,
- text=True,
- capture_output=capture_output,
+
+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},
)
- if check and result.returncode != 0:
- stderr = result.stderr.strip() if result.stderr else ""
- stdout = result.stdout.strip() if result.stdout else ""
- message = stderr if stderr else stdout
- raise ReleaseError(message or f"git {' '.join(args)} failed")
- return result
if __name__ == "__main__":
From 70a6d4b1b4171ec0d49220439937d8140720d4a1 Mon Sep 17 00:00:00 2001
From: Michael Bolin
Date: Fri, 5 Sep 2025 22:08:34 -0700
Subject: [PATCH 14/30] fix: change create_github_release to take either
--publish-alpha or --publish-release (#3231)
No more picking out version numbers by hand! Now we let the script do
it:
```
$ ./codex-rs/scripts/create_github_release --dry-run --publish-alpha
Running gh api GET /repos/openai/codex/releases/latest
Running gh api GET /repos/openai/codex/releases?per_page=100
Publishing version 0.31.0-alpha.3
$ ./codex-rs/scripts/create_github_release --dry-run --publish-release
Running gh api GET /repos/openai/codex/releases/latest
Publishing version 0.31.0
```
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3230).
* __->__ #3231
* #3230
* #3228
* #3226
---
codex-rs/scripts/create_github_release | 102 +++++++++++++++++++++++--
docs/release_management.md | 19 +++--
2 files changed, 108 insertions(+), 13 deletions(-)
diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release
index 08f0b11fc7..120e063541 100755
--- a/codex-rs/scripts/create_github_release
+++ b/codex-rs/scripts/create_github_release
@@ -14,10 +14,24 @@ CARGO_TOML_PATH = "codex-rs/Cargo.toml"
def parse_args(argv: list[str]) -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Create a tagged Codex release.")
+ parser = argparse.ArgumentParser(description="Publish a tagged Codex release.")
parser.add_argument(
- "version",
- help="Version string used for Cargo.toml and the Git tag (e.g. 0.1.0-alpha.4).",
+ "-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:])
@@ -25,6 +39,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
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}")
@@ -34,7 +53,7 @@ def main(argv: list[str]) -> int:
print("Fetching Cargo.toml...")
current_contents = fetch_file_contents(base_commit)
print("Updating version...")
- updated_contents = replace_version(current_contents, args.version)
+ updated_contents = replace_version(current_contents, version)
print("Creating blob...")
blob_sha = create_blob(updated_contents)
print(f"Blob SHA: {blob_sha}")
@@ -42,13 +61,13 @@ def main(argv: list[str]) -> int:
tree_sha = create_tree(base_tree, blob_sha)
print(f"Tree SHA: {tree_sha}")
print("Creating commit...")
- commit_sha = create_commit(args.version, tree_sha, base_commit)
+ commit_sha = create_commit(version, tree_sha, base_commit)
print(f"Commit SHA: {commit_sha}")
print("Creating tag...")
- tag_sha = create_tag(args.version, commit_sha)
+ tag_sha = create_tag(version, commit_sha)
print(f"Tag SHA: {tag_sha}")
print("Creating tag ref...")
- create_tag_ref(args.version, tag_sha)
+ create_tag_ref(version, tag_sha)
print("Done.")
except ReleaseError as error:
print(f"ERROR: {error}", file=sys.stderr)
@@ -59,6 +78,7 @@ def main(argv: list[str]) -> int:
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 = [
@@ -204,5 +224,73 @@ def create_tag_ref(version: str, tag_sha: str) -> None:
)
+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))
diff --git a/docs/release_management.md b/docs/release_management.md
index 1b81bc3eb2..ed12de6e4a 100644
--- a/docs/release_management.md
+++ b/docs/release_management.md
@@ -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.
From 0269096229e8c8bd95185173706807dc10838c7a Mon Sep 17 00:00:00 2001
From: pakrym-oai
Date: Sat, 6 Sep 2025 08:19:23 -0700
Subject: [PATCH 15/30] Move token usage/context information to session level
(#3221)
Move context information into the main loop so it can be used to
interrupt the loop or start auto-compaction.
---
codex-rs/core/src/client.rs | 10 ++-
codex-rs/core/src/codex.rs | 44 +++++++----
.../src/event_processor_with_human_output.rs | 10 ++-
codex-rs/protocol/src/protocol.rs | 74 +++++++++++++++---
codex-rs/tui/src/bottom_pane/chat_composer.rs | 34 +++------
codex-rs/tui/src/bottom_pane/mod.rs | 12 +--
codex-rs/tui/src/chatwidget.rs | 76 ++++++-------------
codex-rs/tui/src/chatwidget/tests.rs | 3 +-
codex-rs/tui/src/history_cell.rs | 5 +-
9 files changed, 151 insertions(+), 117 deletions(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 48e42ee034..52c034ebba 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -398,9 +398,15 @@ impl From 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,
}
}
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 172d4e0ff7..95e9a96951 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -99,6 +99,7 @@ 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;
@@ -261,6 +262,7 @@ struct State {
pending_approvals: HashMap>,
pending_input: Vec,
history: ConversationHistory,
+ token_info: Option,
}
/// Context for an initialized model agent
@@ -1767,15 +1769,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 {
@@ -2841,13 +2851,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();
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index 0ee60b93ff..3c639ad25a 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -189,8 +189,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: {}",
+ usage_info.total_token_usage.blended_total()
+ );
+ }
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 2308b7d212..a422327db3 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -417,9 +417,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),
@@ -521,12 +521,54 @@ pub struct TaskStartedEvent {
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
- pub cached_input_tokens: Option,
+ pub cached_input_tokens: u64,
pub output_tokens: u64,
- pub reasoning_output_tokens: Option,
+ pub reasoning_output_tokens: u64,
pub total_tokens: u64,
}
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct TokenUsageInfo {
+ pub total_token_usage: TokenUsage,
+ pub last_token_usage: TokenUsage,
+ pub model_context_window: Option,
+}
+
+impl TokenUsageInfo {
+ pub fn new_or_append(
+ info: &Option,
+ last: &Option,
+ model_context_window: Option,
+ ) -> Option {
+ 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)]
+pub struct TokenCountEvent {
+ pub info: Option,
+}
+
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: u64 = 12000;
@@ -536,7 +578,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 +596,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 +621,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)]
@@ -606,10 +657,11 @@ impl fmt::Display for FinalOutput {
String::new()
},
token_usage.output_tokens,
- token_usage
- .reasoning_output_tokens
- .map(|r| format!(" (reasoning {r})"))
- .unwrap_or_default()
+ if token_usage.reasoning_output_tokens > 0 {
+ format!(" (reasoning {})", token_usage.reasoning_output_tokens)
+ } else {
+ String::new()
+ }
)
}
}
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 528290fa03..aead743d99 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -1,4 +1,4 @@
-use codex_core::protocol::TokenUsage;
+use codex_core::protocol::TokenUsageInfo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -63,12 +63,6 @@ struct AttachedImage {
path: PathBuf,
}
-struct TokenUsageInfo {
- total_token_usage: TokenUsage,
- last_token_usage: TokenUsage,
- model_context_window: Option,
-}
-
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell,
@@ -166,17 +160,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,
- ) {
- 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) {
+ self.token_usage_info = token_info;
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
@@ -1290,11 +1275,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,
+ ));
}
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index d5daea2ebe..88b3f09646 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -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;
@@ -358,14 +358,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,
- ) {
- 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) {
+ self.composer.set_token_usage(token_info);
self.request_redraw();
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 65ecbf749f..bee9d5e175 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -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;
@@ -109,8 +110,7 @@ pub(crate) struct ChatWidget {
active_exec_cell: Option,
config: Config,
initial_user_message: Option,
- total_token_usage: TokenUsage,
- last_token_usage: TokenUsage,
+ token_info: Option,
// Stream lifecycle controller
stream: StreamController,
running_commands: HashMap,
@@ -259,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) {
+ 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) {
@@ -659,8 +653,7 @@ 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,
@@ -712,8 +705,7 @@ 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,
@@ -1050,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 => {
@@ -1157,9 +1149,16 @@ 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,
+ usage_ref,
&self.session_id,
));
}
@@ -1352,8 +1351,11 @@ 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 {
@@ -1367,12 +1369,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)> {
@@ -1405,34 +1403,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 {
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 555ce50dbc..dfcbbec7d0 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -221,8 +221,7 @@ 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,
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 7e1cf25e92..c8fb59232a 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -966,9 +966,8 @@ pub(crate) fn new_status_output(
" • Input: ".into(),
usage.non_cached_input().to_string().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));
From 58d77ca4e7324cf442e40ef87024631c9206e001 Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Sun, 7 Sep 2025 20:21:53 -0700
Subject: [PATCH 16/30] Clear non-empty prompts with ctrl + c (#3285)
This updates the ctrl + c behavior to clear the current prompt if there
is text and you press ctrl + c.
I also updated the ctrl + c hint text to show `^c to interrupt` instead
of `^c to quit` if there is an active conversation.
Two things I don't love:
1. You can currently interrupt a conversation with escape or ctrl + c
(not related to this PR and maybe fine)
2. The bottom row hint text always says `^c to quit` but this PR doesn't
really make that worse.
https://github.com/user-attachments/assets/6eddadec-0d84-4fa7-abcb-d6f5a04e5748
Fixes https://github.com/openai/codex/issues/3126
---
.../tui/src/bottom_pane/bottom_pane_view.rs | 2 +-
codex-rs/tui/src/bottom_pane/chat_composer.rs | 13 +++++++-
codex-rs/tui/src/bottom_pane/mod.rs | 32 ++++++++++++-------
codex-rs/tui/src/chatwidget.rs | 18 ++++++-----
4 files changed, 44 insertions(+), 21 deletions(-)
diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
index 8f21a242e4..794dd8c422 100644
--- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
+++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
@@ -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.
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index aead743d99..a8dd56b5d5 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -79,6 +79,7 @@ pub(crate) struct ChatComposer {
has_focus: bool,
attached_images: Vec,
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.
@@ -119,6 +120,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(),
@@ -1205,6 +1207,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;
}
@@ -1229,11 +1235,16 @@ impl WidgetRef for ChatComposer {
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let mut hint: Vec> = 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 {
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 88b3f09646..8d84ccc121 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -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() {
@@ -504,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(),
@@ -513,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.
@@ -524,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(),
@@ -555,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(),
@@ -583,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);
@@ -623,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(),
@@ -654,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(),
@@ -705,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(),
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index bee9d5e175..678eb1833a 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -1288,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 {
From c8fab5137290b158e57829ef096dfda28af894cd Mon Sep 17 00:00:00 2001
From: Gabriel Peal
Date: Sun, 7 Sep 2025 20:22:25 -0700
Subject: [PATCH 17/30] Use ConversationId instead of raw Uuids (#3282)
We're trying to migrate from `session_id: Uuid` to `conversation_id:
ConversationId`. Not only does this give us more type safety but it
unifies our terminology across Codex and with the implementation of
session resuming, a conversation (which can span multiple sessions) is
more appropriate.
I started this impl on https://github.com/openai/codex/pull/3219 as part
of getting resume working in the extension but it's big enough that it
should be broken out.
---
codex-rs/core/src/client.rs | 14 +++--
codex-rs/core/src/codex.rs | 34 ++++++-----
codex-rs/core/src/conversation_manager.rs | 20 +++----
codex-rs/core/src/error.rs | 4 +-
codex-rs/core/src/message_history.rs | 15 +++--
codex-rs/core/src/rollout/recorder.rs | 23 ++++----
.../core/tests/chat_completions_payload.rs | 4 +-
codex-rs/core/tests/chat_completions_sse.rs | 4 +-
codex-rs/core/tests/suite/client.rs | 10 ++--
.../src/event_processor_with_human_output.rs | 4 +-
.../mcp-server/src/codex_message_processor.rs | 22 +++----
codex-rs/mcp-server/src/codex_tool_config.rs | 19 ++++---
codex-rs/mcp-server/src/codex_tool_runner.rs | 12 ++--
codex-rs/mcp-server/src/message_processor.rs | 48 ++++++++++------
codex-rs/mcp-server/src/outgoing_message.rs | 7 ++-
.../mcp-server/tests/suite/list_resume.rs | 2 +-
codex-rs/protocol/src/mcp_protocol.rs | 20 ++++++-
codex-rs/protocol/src/message_history.rs | 2 +-
codex-rs/protocol/src/protocol.rs | 25 ++++----
codex-rs/tui/src/app_backtrack.rs | 11 ++--
codex-rs/tui/src/chatwidget.rs | 16 +++---
codex-rs/tui/src/chatwidget/tests.rs | 57 ++++++++++---------
codex-rs/tui/src/history_cell.rs | 4 +-
23 files changed, 213 insertions(+), 164 deletions(-)
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 52c034ebba..2050d8e219 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -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>,
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,
}
@@ -197,7 +197,7 @@ impl ModelClient {
store: false,
stream: true,
include,
- prompt_cache_key: Some(self.session_id.to_string()),
+ prompt_cache_key: Some(self.conversation_id.to_string()),
text,
};
@@ -223,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);
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 95e9a96951..639493b427 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -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;
@@ -149,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 = "";
@@ -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.
@@ -269,7 +273,7 @@ struct State {
///
/// 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,
/// Manager for external MCP servers/tools.
@@ -358,7 +362,7 @@ impl Session {
tx_event: Sender,
initial_history: InitialHistory,
) -> anyhow::Result<(Arc, TurnContext)> {
- let session_id = Uuid::new_v4();
+ let conversation_id = ConversationId::from(Uuid::new_v4());
let ConfigureSession {
provider,
model,
@@ -385,7 +389,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, conversation_id, user_instructions.clone());
let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone());
let default_shell_fut = shell::default_user_shell();
@@ -431,7 +435,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(),
@@ -439,7 +443,7 @@ impl Session {
provider.clone(),
model_reasoning_effort,
model_reasoning_summary,
- session_id,
+ conversation_id,
);
let turn_context = TurnContext {
client,
@@ -461,7 +465,7 @@ impl Session {
cwd,
};
let sess = Arc::new(Session {
- session_id,
+ conversation_id,
tx_event: tx_event.clone(),
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
@@ -483,7 +487,7 @@ impl Session {
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,
@@ -1084,7 +1088,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);
@@ -1172,7 +1176,7 @@ async fn submission_loop(
provider,
effort,
summary,
- sess.session_id,
+ sess.conversation_id,
);
let fresh_turn_context = TurnContext {
@@ -1215,7 +1219,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
@@ -1246,7 +1250,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,
}
@@ -1352,7 +1356,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(),
}),
};
diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs
index 78674cd7e7..6cab2760a3 100644
--- a/codex-rs/core/src/conversation_manager.rs
+++ b/codex-rs/core/src/conversation_manager.rs
@@ -4,8 +4,8 @@ use std::sync::Arc;
use crate::AuthManager;
use crate::CodexAuth;
+use codex_protocol::mcp_protocol::ConversationId;
use tokio::sync::RwLock;
-use uuid::Uuid;
use crate::codex::Codex;
use crate::codex::CodexSpawnOk;
@@ -29,7 +29,7 @@ pub enum InitialHistory {
/// 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,
pub session_configured: SessionConfiguredEvent,
}
@@ -37,7 +37,7 @@ pub struct NewConversation {
/// [`ConversationManager`] is responsible for creating conversations and
/// maintaining them in memory.
pub struct ConversationManager {
- conversations: Arc>>>,
+ conversations: Arc>>>,
auth_manager: Arc,
}
@@ -70,13 +70,13 @@ 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,
+ conversation_id,
} = { Codex::spawn(config, auth_manager, InitialHistory::New).await? };
self.finalize_spawn(codex, conversation_id).await
}
@@ -85,7 +85,7 @@ impl ConversationManager {
async fn finalize_spawn(
&self,
codex: Codex,
- conversation_id: Uuid,
+ conversation_id: ConversationId,
) -> CodexResult {
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
@@ -116,7 +116,7 @@ impl ConversationManager {
pub async fn get_conversation(
&self,
- conversation_id: Uuid,
+ conversation_id: ConversationId,
) -> CodexResult> {
let conversations = self.conversations.read().await;
conversations
@@ -134,12 +134,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 +161,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
diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs
index 00ac145c2e..36b7abe677 100644
--- a/codex-rs/core/src/error.rs
+++ b/codex-rs/core/src/error.rs
@@ -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 = std::result::Result;
@@ -49,7 +49,7 @@ pub enum CodexErr {
Stream(String, Option),
#[error("no conversation with id: {0}")]
- ConversationNotFound(Uuid),
+ ConversationNotFound(ConversationId),
#[error("session configured event was not the first event in the stream")]
SessionConfiguredNotFirstEvent,
diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs
index 75bc5aa75c..bfdbde6781 100644
--- a/codex-rs/core/src/message_history.rs
+++ b/codex-rs/core/src/message_history.rs
@@ -5,7 +5,7 @@
//! JSON-Lines tooling. Each record has the following schema:
//!
//! ````text
-//! {"session_id":"","ts":,"text":""}
+//! {"conversation_id":"","ts":,"text":""}
//! ````
//!
//! To minimise the chance of interleaved writes when multiple processes are
@@ -22,10 +22,11 @@ use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
+
+use codex_protocol::mcp_protocol::ConversationId;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncReadExt;
-use uuid::Uuid;
use crate::config::Config;
use crate::config_types::HistoryPersistence;
@@ -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(),
};
diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs
index 10fb64be15..66a65a95e9 100644
--- a/codex-rs/core/src/rollout/recorder.rs
+++ b/codex-rs/core/src/rollout/recorder.rs
@@ -5,6 +5,7 @@ use std::fs::{self};
use std::io::Error as IoError;
use std::path::Path;
+use codex_protocol::mcp_protocol::ConversationId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@@ -17,7 +18,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;
@@ -32,7 +32,7 @@ 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,
}
@@ -55,7 +55,7 @@ pub struct SavedSession {
pub items: Vec,
#[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
@@ -94,14 +94,14 @@ impl RolloutRecorder {
/// error so the caller can decide whether to disable persistence.
pub async fn new(
config: &Config,
- uuid: Uuid,
+ conversation_id: ConversationId,
instructions: Option,
) -> std::io::Result {
let LogFileInfo {
file,
- session_id,
+ conversation_id: session_id,
timestamp,
- } = create_log_file(config, uuid)?;
+ } = create_log_file(config, conversation_id)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
@@ -227,13 +227,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 {
+fn create_log_file(
+ config: &Config,
+ conversation_id: ConversationId,
+) -> std::io::Result {
// 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 +255,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result std::io::Result) -> Value {
provider,
effort,
summary,
- Uuid::new_v4(),
+ ConversationId::new(),
);
let mut prompt = Prompt::default();
diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs
index 1df658dad6..6155d15e62 100644
--- a/codex-rs/core/tests/chat_completions_sse.rs
+++ b/codex-rs/core/tests/chat_completions_sse.rs
@@ -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 {
provider,
effort,
summary,
- Uuid::new_v4(),
+ ConversationId::new(),
);
let mut prompt = Prompt::default();
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index cc1369e76f..70e6e2b670 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -242,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."
@@ -299,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");
@@ -477,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::().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");
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index 3c639ad25a..71e718c5a7 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -517,7 +517,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: _,
@@ -528,7 +528,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);
diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs
index 828cf1f167..d7502b2376 100644
--- a/codex-rs/mcp-server/src/codex_message_processor.rs
+++ b/codex-rs/mcp-server/src/codex_message_processor.rs
@@ -99,7 +99,7 @@ pub(crate) struct CodexMessageProcessor {
conversation_listeners: HashMap>,
active_login: Arc>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
- pending_interrupts: Arc>>>,
+ pending_interrupts: Arc>>>,
}
impl CodexMessageProcessor {
@@ -511,7 +511,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;
@@ -632,7 +632,7 @@ impl CodexMessageProcessor {
// 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(),
};
@@ -656,7 +656,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 +704,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 +750,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 +765,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 +780,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;
@@ -898,7 +898,7 @@ async fn apply_bespoke_event_handling(
conversation_id: ConversationId,
conversation: Arc,
outgoing: Arc,
- pending_interrupts: Arc>>>,
+ pending_interrupts: Arc>>>,
) {
let Event { id: event_id, msg } = event;
match msg {
@@ -951,7 +951,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 {
diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs
index 0ba5593fcb..7b635d2dc3 100644
--- a/codex-rs/mcp-server/src/codex_tool_config.rs
+++ b/codex-rs/mcp-server/src/codex_tool_config.rs
@@ -181,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,
@@ -213,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,
}
@@ -308,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",
},
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index 48d520b5d6..4be144856b 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -18,13 +18,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;
@@ -43,7 +43,7 @@ pub async fn run_codex_tool_session(
config: CodexConfig,
outgoing: Arc,
conversation_manager: Arc,
- running_requests_id_to_codex_uuid: Arc>>,
+ running_requests_id_to_codex_uuid: Arc>>,
) {
let NewConversation {
conversation_id,
@@ -119,13 +119,13 @@ pub async fn run_codex_tool_session_reply(
outgoing: Arc,
request_id: RequestId,
prompt: String,
- running_requests_id_to_codex_uuid: Arc>>,
- session_id: Uuid,
+ running_requests_id_to_codex_uuid: Arc>>,
+ 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 +154,7 @@ async fn run_codex_tool_session_inner(
codex: Arc,
outgoing: Arc,
request_id: RequestId,
- running_requests_id_to_codex_uuid: Arc>>,
+ running_requests_id_to_codex_uuid: Arc>>,
) {
let request_id_str = match &request_id {
RequestId::String(s) => s.clone(),
diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs
index bf5dbf9804..8179cdd679 100644
--- a/codex-rs/mcp-server/src/message_processor.rs
+++ b/codex-rs/mcp-server/src/message_processor.rs
@@ -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,
conversation_manager: Arc,
- running_requests_id_to_codex_uuid: Arc>>,
+ running_requests_id_to_codex_uuid: Arc>>,
}
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::(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;
}
};
diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs
index b735f2bf9c..537d29db62 100644
--- a/codex-rs/mcp-server/src/outgoing_message.rs
+++ b/codex-rs/mcp-server/src/outgoing_message.rs
@@ -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::();
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::();
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,
diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/mcp-server/tests/suite/list_resume.rs
index d835e5ca77..987349228f 100644
--- a/codex-rs/mcp-server/tests/suite/list_resume.rs
+++ b/codex-rs/mcp-server/tests/suite/list_resume.rs
@@ -142,7 +142,7 @@ async fn test_list_and_resume_conversations() {
} = to_response::(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) {
diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs
index 26d39eb6a8..a8d4b1807f 100644
--- a/codex-rs/protocol/src/mcp_protocol.rs
+++ b/codex-rs/protocol/src/mcp_protocol.rs
@@ -19,16 +19,34 @@ 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, Default)]
#[ts(type = "string")]
pub struct ConversationId(pub Uuid);
+impl ConversationId {
+ pub fn new() -> Self {
+ Self(Uuid::new_v4())
+ }
+}
+
impl Display for ConversationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
+impl From for ConversationId {
+ fn from(value: Uuid) -> Self {
+ Self(value)
+ }
+}
+
+impl From for Uuid {
+ fn from(value: ConversationId) -> Self {
+ value.0
+ }
+}
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
#[ts(type = "string")]
pub struct GitSha(pub String);
diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs
index 3a561df7bf..5d3799a540 100644
--- a/codex-rs/protocol/src/message_history.rs
+++ b/codex-rs/protocol/src/message_history.rs
@@ -3,7 +3,7 @@ use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HistoryEntry {
- pub session_id: String,
+ pub conversation_id: String,
pub ts: u64,
pub text: String,
}
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index a422327db3..6aa9d136bc 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -10,7 +10,14 @@ 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::parse_command::ParsedCommand;
+use crate::plan_tool::UpdatePlanArgs;
use mcp_types::CallToolResult;
use mcp_types::Tool as McpTool;
use serde::Deserialize;
@@ -18,14 +25,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.
@@ -791,7 +790,7 @@ pub struct WebSearchEndEvent {
/// in-memory transcript.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConversationHistoryResponseEvent {
- pub conversation_id: Uuid,
+ pub conversation_id: ConversationId,
pub entries: Vec,
}
@@ -931,8 +930,8 @@ pub struct ListCustomPromptsResponseEvent {
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
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,
@@ -1014,11 +1013,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,
diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs
index c716d6c4ab..b893fd79d0 100644
--- a/codex-rs/tui/src/app_backtrack.rs
+++ b/codex-rs/tui/src/app_backtrack.rs
@@ -4,6 +4,7 @@ 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;
@@ -14,13 +15,13 @@ 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,
+ pub(crate) base_id: Option,
/// 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 +92,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 +136,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 +152,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);
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 678eb1833a..dca821f255 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -85,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 {
@@ -121,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,
+ conversation_id: Option,
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
@@ -163,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);
@@ -660,7 +660,7 @@ impl ChatWidget {
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,
@@ -712,7 +712,7 @@ impl ChatWidget {
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,
@@ -1159,7 +1159,7 @@ impl ChatWidget {
self.add_to_history(history_cell::new_status_output(
&self.config,
usage_ref,
- &self.session_id,
+ &self.conversation_id,
));
}
@@ -1360,8 +1360,8 @@ impl ChatWidget {
.unwrap_or_default()
}
- pub(crate) fn session_id(&self) -> Option {
- self.session_id
+ pub(crate) fn conversation_id(&self) -> Option {
+ self.conversation_id
}
/// Return a reference to the widget's current config (includes any
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index dfcbbec7d0..e423e80440 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -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),
}),
@@ -185,7 +187,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 +210,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(),
@@ -228,10 +230,10 @@ fn make_chatwidget_manual() -> (
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)
@@ -367,11 +369,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_core::parse_command::parse_command(&command)
- .into_iter()
- .map(Into::into)
- .collect();
+ let parsed_cmd: Vec = 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 {
@@ -412,7 +413,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"));
@@ -620,7 +621,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");
@@ -805,7 +806,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(),
@@ -835,7 +836,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(),
@@ -861,10 +862,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 {
@@ -910,7 +911,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,
}),
});
@@ -1344,7 +1345,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();
@@ -1363,8 +1364,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;
@@ -1389,7 +1390,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();
@@ -1691,7 +1692,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.
@@ -1706,7 +1707,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
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index c8fb59232a..8dd9154c51 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -27,6 +27,7 @@ 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::parse_command::ParsedCommand;
use image::DynamicImage;
use image::ImageReader;
@@ -49,7 +50,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 {
@@ -821,7 +821,7 @@ pub(crate) fn new_completed_mcp_tool_call(
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
- session_id: &Option,
+ session_id: &Option,
) -> PlainHistoryCell {
let mut lines: Vec> = Vec::new();
lines.push("/status".magenta().into());
From d84a799ec0c5f6d1fd17410b26505d4ce234198e Mon Sep 17 00:00:00 2001
From: Aleksandr Kondrashov <116561995+aramikuto@users.noreply.github.com>
Date: Mon, 8 Sep 2025 23:15:12 +0900
Subject: [PATCH 18/30] docs: fix broken link to the "Memory with AGENTS.md"
section in codex/README.md (#3300)
Fixes https://github.com/openai/codex/issues/3299
Updated the link in README.md so that it correctly points to the [Memory
with
AGENTS.md](https://github.com/openai/codex/blob/main/docs/getting-started.md#memory-with-agentsmd)
section, ensuring users are directed to the right location.
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 668a03acaf..8f7e624eea 100644
--- a/README.md
+++ b/README.md
@@ -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)
From 6efb52e545aad2b15c102d51ce1fd174b345021e Mon Sep 17 00:00:00 2001
From: dolan <173844978+dolan-openai@users.noreply.github.com>
Date: Mon, 8 Sep 2025 08:12:08 -0700
Subject: [PATCH 19/30] feat(mcp): per-server startup timeout (#3182)
Seeing timeouts on certain, slow mcp server starting up when codex is
invoked. Before this change, the timeout was a hard-coded 10s. Need the
ability to define arbitrary timeouts on a per-server basis.
## Summary of changes
- Add startup_timeout_ms to McpServerConfig with 10s default when unset
- Use per-server timeout for initialize and tools/list
- Introduce ManagedClient to store client and timeout; rename
LIST_TOOLS_TIMEOUT to DEFAULT_STARTUP_TIMEOUT
- Update docs to document startup_timeout_ms with example and options
table
---------
Co-authored-by: Matthew Dolan
---
codex-rs/core/src/config_types.rs | 4 ++
codex-rs/core/src/mcp_connection_manager.rs | 57 ++++++++++++++-------
docs/config.md | 5 ++
3 files changed, 47 insertions(+), 19 deletions(-)
diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs
index 27c2712be7..0722dfc0ea 100644
--- a/codex-rs/core/src/config_types.rs
+++ b/codex-rs/core/src/config_types.rs
@@ -18,6 +18,10 @@ pub struct McpServerConfig {
#[serde(default)]
pub env: Option>,
+
+ /// Startup timeout in milliseconds for initializing MCP server & initially listing tools.
+ #[serde(default)]
+ pub startup_timeout_ms: Option,
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs
index b5813a0462..563a4e13e9 100644
--- a/codex-rs/core/src/mcp_connection_manager.rs
+++ b/codex-rs/core/src/mcp_connection_manager.rs
@@ -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,
+ 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>,
+ clients: HashMap,
/// Fully qualified tool name -> tool instance.
tools: HashMap,
@@ -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,20 @@ impl McpConnectionManager {
});
}
- let mut clients: HashMap> =
- HashMap::with_capacity(join_set.len());
+ let mut clients: HashMap = HashMap::with_capacity(join_set.len());
while let Some(res) = join_set.join_next().await {
let (server_name, client_res) = res?; // JoinError propagation
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);
@@ -212,6 +233,7 @@ impl McpConnectionManager {
.clients
.get(server)
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?
+ .client
.clone();
client
@@ -229,21 +251,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>,
-) -> Result> {
+async fn list_all_tools(clients: &HashMap) -> Result> {
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)
});
}
diff --git a/docs/config.md b/docs/config.md
index d17eab477d..a7f52249a0 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -334,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
@@ -358,6 +360,8 @@ Should be represented as follows in `~/.codex/config.toml`:
command = "npx"
args = ["-y", "mcp-server"]
env = { "API_KEY" = "value" }
+# Optional: override the default 10s startup timeout
+startup_timeout_ms = 20_000
```
## shell_environment_policy
@@ -574,6 +578,7 @@ Options that are specific to the TUI.
| `mcp_servers..command` | string | MCP server launcher command. |
| `mcp_servers..args` | array | MCP server args. |
| `mcp_servers..env` | map | MCP server env vars. |
+| `mcp_servers..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.