chore(codex-rs): clean up tree; add CLI order_replay

- Remove legacy TUI test suite and large fixtures
- Add cli/src/bin/order_replay.rs for output-order analysis
- Update TUI streaming and widgets; minor core protocol/client tweaks
- Update Cargo manifests/lockfile

Build verified with ./build-fast.sh
This commit is contained in:
James Peter
2025-09-03 19:47:03 +10:00
parent 351642b114
commit 06daa92b31
62 changed files with 1487 additions and 33536 deletions
+1
View File
@@ -810,6 +810,7 @@ dependencies = [
"codex-protocol",
"codex-protocol-ts",
"codex-tui",
"regex",
"serde_json",
"tokio",
"tracing",
+1
View File
@@ -28,6 +28,7 @@ codex-mcp-server = { path = "../mcp-server" }
codex-protocol = { path = "../protocol" }
codex-tui = { path = "../tui" }
serde_json = "1"
regex = "1"
tokio = { version = "1", features = [
"io-std",
"macros",
+106
View File
@@ -0,0 +1,106 @@
use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Key(u64);
fn decode_key(k: u64) -> (bool, u64, u64, u64) {
// returns (unordered, request_ordinal, output_index, sequence_number)
let unordered = (k >> 63) == 1;
let req = (k >> 48) & 0xFFFF;
let out = (k >> 32) & 0xFFFF;
let seq = k & 0xFFFF_FFFF;
(unordered, req, out, seq)
}
fn parse_response_expected(path: &Path) -> Result<Vec<(u64, u64)>> {
// Returns vector of (out, seq) in the order they appear if we sort by out then seq
// We only consider events that carry output_index and sequence_number
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let v: Value = serde_json::from_str(&data)?;
let events = v.get("events").and_then(|e| e.as_array()).cloned().unwrap_or_default();
let mut items: Vec<(u64, u64)> = Vec::new();
for ev in events {
let data = ev.get("data");
if let Some(d) = data {
let out = d.get("output_index").and_then(|x| x.as_u64());
let seq = d.get("sequence_number").and_then(|x| x.as_u64());
if let (Some(out), Some(seq)) = (out, seq) {
items.push((out, seq));
}
}
}
items.sort();
Ok(items)
}
#[derive(Debug, Deserialize)]
struct InsertLog {
seq: u64,
ordered: bool,
req: u64,
out: u64,
item_seq: u64,
}
fn parse_tui_inserts(path: &Path) -> Result<Vec<InsertLog>> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let re = Regex::new(r"insert window: seq=(?P<seq>\d+) \((?P<kind>[OU]):(?:req=(?P<req>\d+) out=(?P<out>\d+) seq=(?P<iseq>\d+)|(?P<uval>\d+))\)").unwrap();
let mut out = Vec::new();
for line in text.lines() {
if let Some(caps) = re.captures(line) {
let seq: u64 = caps.name("seq").unwrap().as_str().parse().unwrap_or(0);
let ordered = &caps["kind"] == "O";
let (req, out_idx, item_seq) = if ordered {
let req = caps.name("req").unwrap().as_str().parse().unwrap_or(0);
let out_idx = caps.name("out").unwrap().as_str().parse().unwrap_or(0);
let iseq = caps.name("iseq").unwrap().as_str().parse().unwrap_or(0);
(req, out_idx, iseq)
} else {
(0, 0, caps.name("uval").unwrap().as_str().parse().unwrap_or(0))
};
out.push(InsertLog { seq, ordered, req, out: out_idx, item_seq });
}
}
Ok(out)
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
eprintln!("order-replay: usage: order_replay <response.json> <codex-tui.log>");
let response = args.next().context("missing response.json path")?;
let log = args.next().context("missing codex-tui.log path")?;
let expected = parse_response_expected(Path::new(&response))?;
let actual = parse_tui_inserts(Path::new(&log))?;
println!("Expected (first 20):");
for (i, (out, seq)) in expected.iter().take(20).enumerate() {
println!(" {:>3}: out={} seq={}", i, out, seq);
}
println!("\nActual inserts (first 40):");
for (i, log) in actual.iter().take(40).enumerate() {
if log.ordered {
println!(" {:>3}: O:req={} out={} seq={} (raw={})", i, log.req, log.out, log.item_seq, log.seq);
} else {
println!(" {:>3}: U:{}", i, log.item_seq);
}
}
// Quick check: find first O:req=1 out=2 and O:req=1 out=1 positions
let pos_out1 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 1);
let pos_out2 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 2);
if let (Some(p1), Some(p2)) = (pos_out1, pos_out2) {
println!("\nCheck: first out=1 at {}, first out=2 at {} => {}", p1, p2, if p1 < p2 {"OK"} else {"WRONG"});
} else {
println!("\nCheck: missing ordered inserts for out=1 or out=2 in req=1");
}
Ok(())
}
+93
View File
@@ -70,6 +70,10 @@ enum Subcommand {
/// Internal debugging commands.
Debug(DebugArgs),
/// Debug: replay ordering from response.json and codex-tui.log
#[clap(hide = false)]
OrderReplay(OrderReplayArgs),
/// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree.
#[clap(visible_alias = "a")]
Apply(ApplyCommand),
@@ -136,6 +140,14 @@ struct GenerateTsCommand {
prettier: Option<PathBuf>,
}
#[derive(Debug, Parser)]
struct OrderReplayArgs {
/// Path to a response.json captured under ~/.codex/debug_logs/*_response.json
response_json: std::path::PathBuf,
/// Path to codex-tui.log (typically ~/.codex/log/codex-tui.log)
tui_log: std::path::PathBuf,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
@@ -213,6 +225,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::GenerateTs(gen_cli)) => {
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
}
Some(Subcommand::OrderReplay(args)) => {
order_replay_main(args)?;
}
}
Ok(())
@@ -234,3 +249,81 @@ fn print_completion(cmd: CompletionCommand) {
let name = "codex";
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
}
fn order_replay_main(args: OrderReplayArgs) -> anyhow::Result<()> {
use anyhow::{Context, Result};
use regex::Regex;
use serde_json::Value;
use std::fs;
fn parse_response_expected(path: &std::path::Path) -> Result<Vec<(u64, u64)>> {
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let v: Value = serde_json::from_str(&data)?;
let events = v.get("events").and_then(|e| e.as_array()).cloned().unwrap_or_default();
let mut items: Vec<(u64, u64)> = Vec::new();
for ev in events {
let data = ev.get("data");
if let Some(d) = data {
let out = d.get("output_index").and_then(|x| x.as_u64());
let seq = d.get("sequence_number").and_then(|x| x.as_u64());
if let (Some(out), Some(seq)) = (out, seq) {
items.push((out, seq));
}
}
}
items.sort();
Ok(items)
}
#[derive(Debug)]
struct InsertLog { ordered: bool, req: u64, out: u64, item_seq: u64, raw: u64 }
fn parse_tui_inserts(path: &std::path::Path) -> Result<Vec<InsertLog>> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let re = Regex::new(r"insert window: seq=(?P<seq>\d+) \((?P<kind>[OU]):(?:req=(?P<req>\d+) out=(?P<out>\d+) seq=(?P<iseq>\d+)|(?P<uval>\d+))\)").unwrap();
let mut out = Vec::new();
for line in text.lines() {
if let Some(caps) = re.captures(line) {
let seq: u64 = caps.name("seq").unwrap().as_str().parse().unwrap_or(0);
let ordered = &caps["kind"] == "O";
let (req, out_idx, item_seq) = if ordered {
let req = caps.name("req").unwrap().as_str().parse().unwrap_or(0);
let out_idx = caps.name("out").unwrap().as_str().parse().unwrap_or(0);
let iseq = caps.name("iseq").unwrap().as_str().parse().unwrap_or(0);
(req, out_idx, iseq)
} else {
(0, 0, caps.name("uval").unwrap().as_str().parse().unwrap_or(0))
};
out.push(InsertLog { ordered, req, out: out_idx, item_seq, raw: seq });
}
}
Ok(out)
}
let expected = parse_response_expected(&args.response_json)?;
let actual = parse_tui_inserts(&args.tui_log)?;
println!("Expected (first 20 sorted by out,seq):");
for (i, (out, seq)) in expected.iter().take(20).enumerate() {
println!(" {:>3}: out={} seq={}", i, out, seq);
}
println!("\nActual inserts (first 40):");
for (i, log) in actual.iter().take(40).enumerate() {
if log.ordered {
println!(" {:>3}: O:req={} out={} seq={} (raw={})", i, log.req, log.out, log.item_seq, log.raw);
} else {
println!(" {:>3}: U:{}", i, log.item_seq);
}
}
// Simple check: assistant (out=1) should appear before tool (out=2) within same req
let pos_out1 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 1);
let pos_out2 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 2);
println!("\nCheck (req=1): first out=1 at {:?}, first out=2 at {:?}", pos_out1, pos_out2);
if let (Some(p1), Some(p2)) = (pos_out1, pos_out2) {
if p1 < p2 { println!("Result: OK (assistant precedes tool)"); } else { println!("Result: WRONG (tool precedes assistant)"); }
}
Ok(())
}
+2
View File
@@ -51,7 +51,9 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let synthetic_event = Event {
// Fake id value.
id: "".to_string(),
event_seq: 0,
msg: EventMsg::SessionConfigured(session_configured),
order: None,
};
let session_configured_event = match serde_json::to_string(&synthetic_event) {
Ok(s) => s,
+3 -1
View File
@@ -111,14 +111,16 @@ impl AgentManager {
let event = Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents,
context,
task,
}),
order: None,
};
let _ = sender.send(event);
let _ = sender.send(Event { order: None, ..event });
}
}
+20 -12
View File
@@ -367,7 +367,7 @@ async fn process_chat_sse<S>(
}],
id: current_item_id.clone(),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
if !reasoning_text.is_empty() {
@@ -379,7 +379,7 @@ async fn process_chat_sse<S>(
}]),
encrypted_content: None,
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
let _ = tx_event
@@ -432,6 +432,8 @@ async fn process_chat_sse<S>(
.send(Ok(ResponseEvent::OutputTextDelta {
delta: content.to_string(),
item_id: current_item_id.clone(),
sequence_number: None,
output_index: None,
}))
.await;
}
@@ -464,6 +466,8 @@ async fn process_chat_sse<S>(
.send(Ok(ResponseEvent::ReasoningContentDelta {
delta: reasoning,
item_id: current_item_id.clone(),
sequence_number: None,
output_index: None,
}))
.await;
}
@@ -514,7 +518,7 @@ async fn process_chat_sse<S>(
}]),
encrypted_content: None,
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
// Then emit the FunctionCall response item.
@@ -525,7 +529,7 @@ async fn process_chat_sse<S>(
call_id: fn_call_state.call_id.clone().unwrap_or_else(String::new),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
"stop" => {
// Regular turn without tool-call. Emit the final assistant message
@@ -538,7 +542,7 @@ async fn process_chat_sse<S>(
}],
id: current_item_id.clone(),
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
// Also emit a terminal Reasoning item so UIs can finalize raw reasoning.
if !reasoning_text.is_empty() {
@@ -550,7 +554,7 @@ async fn process_chat_sse<S>(
}]),
encrypted_content: None,
};
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await;
let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })).await;
}
}
_ => {}
@@ -627,7 +631,7 @@ where
Poll::Pending => return Poll::Pending,
Poll::Ready(None) => return Poll::Ready(None),
Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))) => {
Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone { item, sequence_number: _, .. }))) => {
// If this is an incremental assistant message chunk, accumulate but
// do NOT emit yet. Forward any other item (e.g. FunctionCall) right
// away so downstream consumers see it.
@@ -666,7 +670,7 @@ where
}
// Not an assistant message – forward immediately.
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item))));
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None })));
}
Poll::Ready(Some(Ok(ResponseEvent::Completed {
response_id,
@@ -689,7 +693,7 @@ where
encrypted_content: None,
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_reasoning));
.push_back(ResponseEvent::OutputItemDone { item: aggregated_reasoning, sequence_number: None, output_index: None });
emitted_any = true;
}
@@ -702,7 +706,7 @@ where
}],
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_message));
.push_back(ResponseEvent::OutputItemDone { item: aggregated_message, sequence_number: None, output_index: None });
emitted_any = true;
}
@@ -729,7 +733,7 @@ where
// will never appear in a Chat Completions stream.
continue;
}
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { delta, item_id }))) => {
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, .. }))) => {
// Always accumulate deltas so we can emit a final OutputItemDone at Completed.
this.cumulative.push_str(&delta);
// Capture the item_id if we haven't already
@@ -741,12 +745,14 @@ where
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta {
delta,
item_id,
sequence_number,
output_index: None,
})));
} else {
continue;
}
}
Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { delta, item_id }))) => {
Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { delta, item_id, sequence_number, .. }))) => {
// Always accumulate reasoning deltas so we can emit a final Reasoning item at Completed.
this.cumulative_reasoning.push_str(&delta);
// Capture the item_id if we haven't already
@@ -758,6 +764,8 @@ where
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta {
delta,
item_id,
sequence_number,
output_index: None,
})));
} else {
continue;
+7 -1
View File
@@ -662,7 +662,7 @@ async fn process_sse<S>(
}
}
let event = ResponseEvent::OutputItemDone(item);
let event = ResponseEvent::OutputItemDone { item, sequence_number: event.sequence_number, output_index: event.output_index };
if tx_event.send(Ok(event)).await.is_err() {
return;
}
@@ -677,6 +677,8 @@ async fn process_sse<S>(
let ev = ResponseEvent::OutputTextDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
@@ -714,6 +716,8 @@ async fn process_sse<S>(
let ev = ResponseEvent::ReasoningSummaryDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
@@ -752,6 +756,8 @@ async fn process_sse<S>(
let ev = ResponseEvent::ReasoningContentDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
+7 -1
View File
@@ -167,7 +167,7 @@ impl Prompt {
#[derive(Debug)]
pub enum ResponseEvent {
Created,
OutputItemDone(ResponseItem),
OutputItemDone { item: ResponseItem, sequence_number: Option<u64>, output_index: Option<u32> },
Completed {
response_id: String,
token_usage: Option<TokenUsage>,
@@ -175,14 +175,20 @@ pub enum ResponseEvent {
OutputTextDelta {
delta: String,
item_id: Option<String>,
sequence_number: Option<u64>,
output_index: Option<u32>,
},
ReasoningSummaryDelta {
delta: String,
item_id: Option<String>,
sequence_number: Option<u64>,
output_index: Option<u32>,
},
ReasoningContentDelta {
delta: String,
item_id: Option<String>,
sequence_number: Option<u64>,
output_index: Option<u32>,
},
ReasoningSummaryPartAdded,
WebSearchCallBegin {
+215 -213
View File
@@ -539,6 +539,10 @@ struct State {
/// Scratchpad that buffers streamed items/deltas for the current HTTP attempt
/// so we can seed retries without losing progress.
turn_scratchpad: Option<TurnScratchpad>,
/// Per-submission monotonic event sequence (resets at TaskStarted)
event_seq_by_sub_id: HashMap<String, u64>,
/// 1-based ordinal of the current HTTP request attempt in this session.
request_ordinal: u64,
}
/// Buffers partial turn progress produced during a single HTTP streaming attempt.
@@ -636,6 +640,13 @@ impl Session {
state.turn_scratchpad = Some(TurnScratchpad::default());
}
/// Bump the per-session HTTP request attempt ordinal so `OrderMeta`
/// reflects the correct provider request index for this attempt.
fn begin_http_attempt(&self) {
let mut state = self.state.lock().unwrap();
state.request_ordinal = state.request_ordinal.saturating_add(1);
}
fn scratchpad_push(&self, item: &ResponseItem, response: &Option<ResponseInputItem>) {
let mut state = self.state.lock().unwrap();
if let Some(sp) = &mut state.turn_scratchpad {
@@ -711,7 +722,7 @@ impl Session {
}
}
/// Sends the given event to the client and swallows the send event, if
/// Sends the given event to the client and swallows the send error, if
/// any, logging it as an error.
pub(crate) async fn send_event(&self, event: Event) {
if let Err(e) = self.tx_event.send(event).await {
@@ -719,6 +730,62 @@ impl Session {
}
}
/// Create a stamped Event with a per-turn sequence number.
fn make_event(&self, sub_id: &str, msg: EventMsg) -> Event {
let mut state = self.state.lock().unwrap();
let seq = match msg {
EventMsg::TaskStarted => {
// Reset per-sub_id sequence at the start of a turn.
// We increment request_ordinal per HTTP attempt instead
// (see `begin_http_attempt`).
let e = state
.event_seq_by_sub_id
.entry(sub_id.to_string())
.or_insert(0);
*e = 0;
0
}
_ => {
let e = state
.event_seq_by_sub_id
.entry(sub_id.to_string())
.or_insert(0);
*e = e.saturating_add(1);
*e
}
};
Event { id: sub_id.to_string(), event_seq: seq, msg, order: None }
}
/// Same as make_event but allows supplying a provider sequence_number
/// (e.g., Responses API SSE event). We DO NOT overwrite `event_seq`
/// with this hint because `event_seq` must remain monotonic per turn
/// and local to our runtime. Provider ordering is carried via
/// `OrderMeta` when applicable.
fn make_event_with_hint(&self, sub_id: &str, msg: EventMsg, _seq_hint: Option<u64>) -> Event {
// Preserve the monotonic invariant of event_seq by delegating to make_event.
// Any ordering hints from the provider should be conveyed through
// OrderMeta (see make_event_with_order) rather than event_seq.
self.make_event(sub_id, msg)
}
fn make_event_with_order(
&self,
sub_id: &str,
msg: EventMsg,
order: crate::protocol::OrderMeta,
seq_hint: Option<u64>,
) -> Event {
let mut ev = self.make_event_with_hint(sub_id, msg, seq_hint);
ev.order = Some(order);
ev
}
fn current_request_ordinal(&self) -> u64 {
let state = self.state.lock().unwrap();
state.request_ordinal
}
pub async fn request_command_approval(
&self,
sub_id: String,
@@ -728,15 +795,15 @@ impl Session {
reason: Option<String>,
) -> oneshot::Receiver<ReviewDecision> {
let (tx_approve, rx_approve) = oneshot::channel();
let event = Event {
id: sub_id.clone(),
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
let event = self.make_event(
&sub_id,
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
command,
cwd,
reason,
}),
};
);
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock().unwrap();
@@ -754,15 +821,15 @@ impl Session {
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
let (tx_approve, rx_approve) = oneshot::channel();
let event = Event {
id: sub_id.clone(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
let event = self.make_event(
&sub_id,
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
changes: convert_apply_patch_to_protocol(action),
reason,
grant_root,
}),
};
);
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock().unwrap();
@@ -939,6 +1006,8 @@ impl Session {
&self,
turn_diff_tracker: &mut TurnDiffTracker,
exec_command_context: ExecCommandContext,
seq_hint: Option<u64>,
output_index: Option<u32>,
) {
let ExecCommandContext {
sub_id,
@@ -967,10 +1036,8 @@ impl Session {
parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
id: sub_id.to_string(),
msg,
};
let order = crate::protocol::OrderMeta { request_ordinal: self.current_request_ordinal(), output_index, sequence_number: seq_hint };
let event = self.make_event_with_order(&sub_id, msg, order, seq_hint);
let _ = self.tx_event.send(event).await;
}
@@ -981,6 +1048,8 @@ impl Session {
call_id: &str,
output: &ExecToolCallOutput,
is_apply_patch: bool,
seq_hint: Option<u64>,
output_index: Option<u32>,
) {
let ExecToolCallOutput {
stdout,
@@ -1012,11 +1081,8 @@ impl Session {
duration: *duration,
})
};
let event = Event {
id: sub_id.to_string(),
msg,
};
let order = crate::protocol::OrderMeta { request_ordinal: self.current_request_ordinal(), output_index, sequence_number: seq_hint };
let event = self.make_event_with_order(sub_id, msg, order, seq_hint);
let _ = self.tx_event.send(event).await;
// If this is an apply_patch, after we emit the end patch, emit a second event
@@ -1025,10 +1091,7 @@ impl Session {
let unified_diff = turn_diff_tracker.get_unified_diff();
if let Ok(Some(unified_diff)) = unified_diff {
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
let event = Event {
id: sub_id.into(),
msg,
};
let event = self.make_event(sub_id, msg);
let _ = self.tx_event.send(event).await;
}
}
@@ -1042,22 +1105,24 @@ impl Session {
turn_diff_tracker: &mut TurnDiffTracker,
begin_ctx: ExecCommandContext,
exec_args: ExecInvokeArgs<'a>,
seq_hint: Option<u64>,
output_index: Option<u32>,
) -> crate::error::Result<ExecToolCallOutput> {
let is_apply_patch = begin_ctx.apply_patch.is_some();
let sub_id = begin_ctx.sub_id.clone();
let call_id = begin_ctx.call_id.clone();
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index)
.await;
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
exec_args.sandbox_policy,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
)
.await;
let result = process_exec_tool_call(
exec_args.params,
exec_args.sandbox_type,
exec_args.sandbox_policy,
exec_args.codex_linux_sandbox_exe,
exec_args.stdout_stream,
)
.await;
let output_stderr;
let borrowed: &ExecToolCallOutput = match &result {
@@ -1079,6 +1144,8 @@ impl Session {
&call_id,
borrowed,
is_apply_patch,
seq_hint.map(|h| h.saturating_add(1)),
output_index,
)
.await;
@@ -1089,22 +1156,18 @@ impl Session {
/// the call‑sites terse so adding more diagnostics does not clutter the
/// core agent logic.
async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: message.into(),
}),
};
let event = self.make_event(
sub_id,
EventMsg::BackgroundEvent(BackgroundEventEvent { message: message.into() }),
);
let _ = self.tx_event.send(event).await;
}
async fn notify_stream_error(&self, sub_id: &str, message: impl Into<String>) {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::Error(ErrorEvent {
message: message.into(),
}),
};
let event = self.make_event(
sub_id,
EventMsg::Error(ErrorEvent { message: message.into() }),
);
let _ = self.tx_event.send(event).await;
}
@@ -1381,15 +1444,12 @@ impl AgentAgent {
// TOCTOU?
if !self.handle.is_finished() {
self.handle.abort();
let event = Event {
id: self.sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "Turn interrupted".to_string(),
}),
};
let stamped = self
.sess
.make_event(&self.sub_id, EventMsg::Error(ErrorEvent { message: "Turn interrupted".to_string() }));
let tx_event = self.sess.tx_event.clone();
tokio::spawn(async move {
tx_event.send(event).await.ok();
tx_event.send(stamped).await.ok();
});
}
}
@@ -1408,10 +1468,9 @@ async fn submission_loop(
let send_no_session_event = |sub_id: String| async {
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "No session initialized, expected 'ConfigureSession' as first Op"
.to_string(),
}),
event_seq: 0,
msg: EventMsg::Error(ErrorEvent { message: "No session initialized, expected 'ConfigureSession' as first Op".to_string() }),
order: None,
};
tx_event.send(event).await.ok();
};
@@ -1451,10 +1510,7 @@ async fn submission_loop(
if !cwd.is_absolute() {
let message = format!("cwd is not absolute: {cwd:?}");
error!(message);
let event = Event {
id: sub.id,
msg: EventMsg::Error(ErrorEvent { message }),
};
let event = Event { id: sub.id, event_seq: 0, msg: EventMsg::Error(ErrorEvent { message }), order: None };
if let Err(e) = tx_event.send(event).await {
error!("failed to send error message: {e:?}");
}
@@ -1545,10 +1601,7 @@ async fn submission_loop(
Err(e) => {
let message = format!("Failed to create MCP connection manager: {e:#}");
error!("{message}");
mcp_connection_errors.push(Event {
id: sub.id.clone(),
msg: EventMsg::Error(ErrorEvent { message }),
});
mcp_connection_errors.push(Event { id: sub.id.clone(), event_seq: 0, msg: EventMsg::Error(ErrorEvent { message }), order: None });
(McpConnectionManager::default(), Default::default())
}
};
@@ -1559,10 +1612,7 @@ async fn submission_loop(
let message =
format!("MCP client for `{server_name}` failed to start: {err:#}");
error!("{message}");
mcp_connection_errors.push(Event {
id: sub.id.clone(),
msg: EventMsg::Error(ErrorEvent { message }),
});
mcp_connection_errors.push(Event { id: sub.id.clone(), event_seq: 0, msg: EventMsg::Error(ErrorEvent { message }), order: None });
}
}
let default_shell = shell::default_user_shell().await;
@@ -1620,12 +1670,14 @@ async fn submission_loop(
// ack
let events = std::iter::once(Event {
id: INITIAL_SUBMIT_ID.to_string(),
event_seq: 0,
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
model,
history_log_id,
history_entry_count,
}),
order: None,
})
.chain(mcp_connection_errors.into_iter());
for event in events {
@@ -1635,10 +1687,7 @@ async fn submission_loop(
}
// If we resumed from a rollout, replay the prior transcript into the UI.
if let Some(items) = restored_items {
let event = Event {
id: sub.id.clone(),
msg: EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent { items }),
};
let event = Event { id: sub.id.clone(), event_seq: 0, msg: EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent { items }), order: None };
if let Err(e) = tx_event.send(event).await {
warn!("failed to send ReplayHistory event: {e}");
}
@@ -1739,6 +1788,7 @@ async fn submission_loop(
let event = Event {
id: sub_id,
event_seq: 0,
msg: EventMsg::GetHistoryEntryResponse(
crate::protocol::GetHistoryEntryResponseEvent {
offset,
@@ -1746,6 +1796,7 @@ async fn submission_loop(
entry: entry_opt,
},
),
order: None,
};
if let Err(e) = tx_event.send(event).await {
@@ -1794,22 +1845,14 @@ async fn submission_loop(
if let Some(rec) = recorder_opt {
if let Err(e) = rec.shutdown().await {
warn!("failed to shutdown rollout recorder: {e}");
let event = Event {
id: sub.id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: "Failed to shutdown rollout recorder".to_string(),
}),
};
let event = Event { id: sub.id.clone(), event_seq: 0, msg: EventMsg::Error(ErrorEvent { message: "Failed to shutdown rollout recorder".to_string() }), order: None };
if let Err(e) = tx_event.send(event).await {
warn!("failed to send error message: {e:?}");
}
}
}
}
let event = Event {
id: sub.id.clone(),
msg: EventMsg::ShutdownComplete,
};
let event = Event { id: sub.id.clone(), event_seq: 0, msg: EventMsg::ShutdownComplete, order: None };
if let Err(e) = tx_event.send(event).await {
warn!("failed to send Shutdown event: {e}");
}
@@ -1837,7 +1880,7 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
if input.is_empty() {
return;
}
let event = Event { id: sub_id.clone(), msg: EventMsg::TaskStarted };
let event = sess.make_event(&sub_id, EventMsg::TaskStarted);
if sess.tx_event.send(event).await.is_err() {
return;
}
@@ -2034,12 +2077,7 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
}
Err(e) => {
info!("Turn error: {e:#}");
let event = Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: e.to_string(),
}),
};
let event = Event { id: sub_id.clone(), event_seq: 0, msg: EventMsg::Error(ErrorEvent { message: e.to_string() }), order: None };
sess.tx_event.send(event).await.ok();
// let the user continue the conversation
break;
@@ -2047,12 +2085,7 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
}
}
sess.remove_agent(&sub_id);
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: last_task_message,
}),
};
let event = Event { id: sub_id, event_seq: 0, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: last_task_message }), order: None };
match &event.msg {
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: Some(m) }) => {
tracing::info!("core.emit TaskComplete last_agent_message.len={}", m.len());
@@ -2082,6 +2115,10 @@ async fn run_turn(
// items from a previous dropped stream attempt so we don't lose progress.
let mut attempt_input: Vec<ResponseItem> = input.clone();
loop {
// Each loop iteration corresponds to a single provider HTTP request.
// Increment the attempt ordinal first so all OrderMeta emitted during
// this attempt share the same `req`.
sess.begin_http_attempt();
// Build status items (screenshots, system status) fresh for each attempt
let status_items = build_turn_status_items(sess).await;
@@ -2312,9 +2349,9 @@ async fn try_run_turn(
match event {
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
ResponseEvent::OutputItemDone { item, sequence_number, output_index } => {
let response =
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone()).await?;
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone(), sequence_number, output_index).await?;
// Save into scratchpad so we can seed a retry if the stream drops later.
sess.scratchpad_push(&item, &response);
@@ -2329,19 +2366,13 @@ async fn try_run_turn(
ResponseEvent::WebSearchCallBegin { call_id } => {
let _ = sess
.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None }),
})
.send(sess.make_event(&sub_id, EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None })))
.await;
}
ResponseEvent::WebSearchCallCompleted { call_id, query } => {
let _ = sess
.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query }),
})
.send(sess.make_event(&sub_id, EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query })))
.await;
}
ResponseEvent::Completed {
@@ -2350,10 +2381,7 @@ async fn try_run_turn(
} => {
if let Some(token_usage) = token_usage {
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
})
.send(sess.make_event(&sub_id, EventMsg::TokenCount(token_usage)))
.await
.ok();
}
@@ -2361,63 +2389,52 @@ async fn try_run_turn(
let unified_diff = turn_diff_tracker.get_unified_diff();
if let Ok(Some(unified_diff)) = unified_diff {
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
let event = Event {
id: sub_id.to_string(),
msg,
};
let _ = sess.tx_event.send(event).await;
let _ = sess.tx_event.send(sess.make_event(&sub_id, msg)).await;
}
return Ok(output);
}
ResponseEvent::OutputTextDelta { delta, item_id } => {
ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, output_index } => {
// Don't append to history during streaming - only send UI events.
// The complete message will be added to history when OutputItemDone arrives.
// This ensures items are recorded in the correct chronological order.
// Use the item_id if present, otherwise fall back to sub_id
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
let event = Event {
id: event_id,
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: delta.clone() }),
let order = crate::protocol::OrderMeta {
request_ordinal: sess.current_request_ordinal(),
output_index,
sequence_number,
};
sess.tx_event.send(event).await.ok();
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: delta.clone() }), order, sequence_number);
sess.tx_event.send(stamped).await.ok();
// Track partial assistant text in the scratchpad to help resume on retry.
// Only accumulate when we have an item context or a single active stream.
// We deliberately do not scope by item_id to keep implementation simple.
sess.scratchpad_add_text_delta(&delta);
}
ResponseEvent::ReasoningSummaryDelta { delta, item_id } => {
ResponseEvent::ReasoningSummaryDelta { delta, item_id, sequence_number, output_index } => {
// Use the item_id if present, otherwise fall back to sub_id
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
let event = Event {
id: event_id,
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }),
};
sess.tx_event.send(event).await.ok();
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number };
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }), order, sequence_number);
sess.tx_event.send(stamped).await.ok();
// Buffer reasoning summary so we can include a hint on retry.
sess.scratchpad_add_reasoning_delta(&delta);
}
ResponseEvent::ReasoningSummaryPartAdded => {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}),
};
sess.tx_event.send(event).await.ok();
let stamped = sess.make_event(&sub_id, EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}));
sess.tx_event.send(stamped).await.ok();
}
ResponseEvent::ReasoningContentDelta { delta, item_id } => {
ResponseEvent::ReasoningContentDelta { delta, item_id, sequence_number, output_index } => {
if sess.show_raw_agent_reasoning {
// Use the item_id if present, otherwise fall back to sub_id
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
let event = Event {
id: event_id,
msg: EventMsg::AgentReasoningRawContentDelta(
AgentReasoningRawContentDeltaEvent { delta },
),
};
sess.tx_event.send(event).await.ok();
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number };
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta }), order, sequence_number);
sess.tx_event.send(stamped).await.ok();
}
}
// Note: ReasoningSummaryPartAdded handled above without scratchpad mutation.
@@ -2431,7 +2448,7 @@ async fn run_compact_agent(
input: Vec<InputItem>,
compact_instructions: String,
) {
let start_event = Event { id: sub_id.clone(), msg: EventMsg::TaskStarted };
let start_event = sess.make_event(&sub_id, EventMsg::TaskStarted);
if sess.tx_event.send(start_event).await.is_err() {
return;
}
@@ -2444,6 +2461,9 @@ async fn run_compact_agent(
let mut retries = 0;
loop {
// Bump request_ordinal for this provider request attempt so
// downstream OrderMeta carries the correct `req` index.
sess.begin_http_attempt();
// Build status items (screenshots, system status) fresh for each attempt
let status_items = build_turn_status_items(&sess).await;
@@ -2482,20 +2502,10 @@ async fn run_compact_agent(
tokio::time::sleep(delay).await;
continue;
} else {
let event = Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: e.to_string(),
}),
};
let event = Event { id: sub_id.clone(), event_seq: 0, msg: EventMsg::Error(ErrorEvent { message: e.to_string() }), order: None };
sess.send_event(event).await;
// Ensure the UI is released from running state even on errors.
let done = Event {
id: sub_id.clone(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
let done = Event { id: sub_id.clone(), event_seq: 0, msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: None }), order: None };
sess.send_event(done).await;
return;
}
@@ -2504,19 +2514,12 @@ async fn run_compact_agent(
}
sess.remove_agent(&sub_id);
let event = Event {
id: sub_id.clone(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Compact agent completed".to_string(),
}),
};
let event = Event { id: sub_id.clone(), event_seq: 0, msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Compact agent completed".to_string() }), order: None };
sess.send_event(event).await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
let event = sess.make_event(
&sub_id,
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: None }),
);
sess.send_event(event).await;
let mut state = sess.state.lock().unwrap();
@@ -2528,6 +2531,8 @@ async fn handle_response_item(
turn_diff_tracker: &mut TurnDiffTracker,
sub_id: &str,
item: ResponseItem,
seq_hint: Option<u64>,
output_index: Option<u32>,
) -> CodexResult<Option<ResponseInputItem>> {
debug!(?item, "Output item");
let output = match item {
@@ -2536,11 +2541,9 @@ async fn handle_response_item(
let event_id = id.unwrap_or_else(|| sub_id.to_string());
for item in content {
if let ContentItem::OutputText { text } = item {
let event = Event {
id: event_id.clone(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
};
sess.tx_event.send(event).await.ok();
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessage(AgentMessageEvent { message: text }), order, seq_hint);
sess.tx_event.send(stamped).await.ok();
}
}
None
@@ -2561,11 +2564,9 @@ async fn handle_response_item(
let text = match item {
ReasoningItemReasoningSummary::SummaryText { text } => text,
};
let event = Event {
id: event_id.clone(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
};
sess.tx_event.send(event).await.ok();
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoning(AgentReasoningEvent { text }), order, seq_hint);
sess.tx_event.send(stamped).await.ok();
}
if sess.show_raw_agent_reasoning && content.is_some() {
let content = content.unwrap();
@@ -2574,13 +2575,9 @@ async fn handle_response_item(
ReasoningItemContent::ReasoningText { text } => text,
ReasoningItemContent::Text { text } => text,
};
let event = Event {
id: event_id.clone(),
msg: EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent {
text,
}),
};
sess.tx_event.send(event).await.ok();
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }), order, seq_hint);
sess.tx_event.send(stamped).await.ok();
}
}
None
@@ -2600,6 +2597,8 @@ async fn handle_response_item(
name,
arguments,
call_id,
seq_hint,
output_index,
)
.await,
)
@@ -2636,14 +2635,16 @@ async fn handle_response_item(
let exec_params = to_exec_params(params, sess);
Some(
handle_container_exec_with_params(
exec_params,
sess,
turn_diff_tracker,
sub_id.to_string(),
effective_call_id,
)
.await,
handle_container_exec_with_params(
exec_params,
sess,
turn_diff_tracker,
sub_id.to_string(),
effective_call_id,
seq_hint,
output_index,
)
.await,
)
}
ResponseItem::CustomToolCall { call_id, name, .. } => {
@@ -2667,10 +2668,7 @@ async fn handle_response_item(
ResponseItem::WebSearchCall { id, action, .. } => {
if let WebSearchAction::Search { query } = action {
let call_id = id.unwrap_or_else(|| "".to_string());
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query: Some(query) }),
};
let event = sess.make_event_with_hint(&sub_id, EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query: Some(query) }), seq_hint);
sess.tx_event.send(event).await.ok();
}
None
@@ -2715,6 +2713,8 @@ async fn handle_function_call(
name: String,
arguments: String,
call_id: String,
seq_hint: Option<u64>,
output_index: Option<u32>,
) -> ResponseInputItem {
match name.as_str() {
"container.exec" | "shell" => {
@@ -2724,7 +2724,7 @@ async fn handle_function_call(
return *output;
}
};
handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id)
handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id, seq_hint, output_index)
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
@@ -4515,12 +4515,7 @@ async fn handle_list_agents(
running_count,
if running_count != 1 { "s" } else { "" }
);
let event = Event {
id: "agent-status".to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: status_msg,
}),
};
let event = Event { id: "agent-status".to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: status_msg }), order: None };
let _ = sess.tx_event.send(event).await;
}
@@ -4594,6 +4589,8 @@ async fn handle_container_exec_with_params(
turn_diff_tracker: &mut TurnDiffTracker,
sub_id: String,
call_id: String,
seq_hint: Option<u64>,
output_index: Option<u32>,
) -> ResponseInputItem {
// Intercept risky git branch-changing commands and require an explicit confirm prefix.
// We support a simple convention: prefix the script with `confirm:` to proceed.
@@ -4934,6 +4931,8 @@ async fn handle_container_exec_with_params(
})
},
},
seq_hint,
output_index,
)
.await;
@@ -5069,6 +5068,8 @@ async fn handle_sandbox_error(
})
},
},
None,
None,
)
.await;
@@ -5262,7 +5263,7 @@ async fn drain_to_completed(sess: &Session, sub_id: &str, prompt: &Prompt) -> Co
));
};
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
Ok(ResponseEvent::OutputItemDone { item, sequence_number: _, output_index: _ }) => {
// Record only to in-memory conversation history; avoid state snapshot.
let mut state = sess.state.lock().unwrap();
state.history.record_items(std::slice::from_ref(&item));
@@ -5274,13 +5275,10 @@ async fn drain_to_completed(sess: &Session, sub_id: &str, prompt: &Prompt) -> Co
// some providers don't return token usage, so we default
// TODO: consider approximate token usage
let token_usage = token_usage.unwrap_or_default();
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
})
.await
.ok();
sess.tx_event
.send(sess.make_event(&sub_id, EventMsg::TokenCount(token_usage)))
.await
.ok();
return Ok(());
}
@@ -5359,11 +5357,13 @@ async fn send_agent_status_update(sess: &Session) {
let event = Event {
id: "agent_status".to_string(),
event_seq: 0,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents,
context: None,
task: None,
}),
order: None,
};
// Send event asynchronously
@@ -5383,10 +5383,12 @@ fn add_pending_screenshot(sess: &Session, screenshot_path: PathBuf, url: String)
// Also send an immediate event to update the TUI display
let event = Event {
id: "browser_screenshot".to_string(),
event_seq: 0,
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path,
url,
}),
order: None,
};
// Send event asynchronously to avoid blocking
@@ -5463,14 +5465,14 @@ where
use std::time::Instant;
// Send begin event
let begin_event = Event {
id: sub_id.to_string(),
msg: EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
let begin_event = sess.make_event(
sub_id,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: call_id.clone(),
tool_name: tool_name.clone(),
parameters: parameters.clone(),
}),
};
);
sess.send_event(begin_event).await;
// Execute the tool
@@ -5489,16 +5491,16 @@ where
};
// Send end event
let end_event = Event {
id: sub_id.to_string(),
msg: EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
let end_event = sess.make_event(
sub_id,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: call_id.clone(),
tool_name,
parameters,
duration,
result: if success { Ok(message) } else { Err(message) },
}),
};
);
sess.send_event(end_event).await;
result
+1 -4
View File
@@ -87,10 +87,7 @@ impl ConversationManager {
// history.
let event = codex.next_event().await?;
let session_configured = match event {
Event {
id,
msg: EventMsg::SessionConfigured(session_configured),
} if id == INITIAL_SUBMIT_ID => session_configured,
Event { id, msg: EventMsg::SessionConfigured(session_configured), .. } if id == INITIAL_SUBMIT_ID => session_configured,
_ => {
return Err(CodexErr::SessionConfiguredNotFirstEvent);
}
+1 -4
View File
@@ -379,10 +379,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
},
chunk: ByteBuf::from(chunk),
});
let event = Event {
id: stream.sub_id.clone(),
msg,
};
let event = Event { id: stream.sub_id.clone(), event_seq: 0, msg, order: None };
#[allow(clippy::let_unit_value)]
let _ = stream.tx_event.send(event).await;
emitted_deltas += 1;
+1 -5
View File
@@ -74,9 +74,5 @@ pub(crate) async fn handle_mcp_tool_call(
}
async fn notify_mcp_tool_call_event(sess: &Session, sub_id: &str, event: EventMsg) {
sess.send_event(Event {
id: sub_id.to_string(),
msg: event,
})
.await;
sess.send_event(Event { id: sub_id.to_string(), event_seq: 0, msg: event, order: None }).await;
}
+1 -4
View File
@@ -79,10 +79,7 @@ pub(crate) async fn handle_update_plan(
},
};
session
.send_event(Event {
id: sub_id.to_string(),
msg: EventMsg::PlanUpdate(args),
})
.send_event(Event { id: sub_id.to_string(), event_seq: 0, msg: EventMsg::PlanUpdate(args), order: None })
.await;
output
}
+20 -1
View File
@@ -369,8 +369,26 @@ pub enum InputItem {
pub struct Event {
/// Submission `id` that this event is correlated with.
pub id: String,
/// Monotonic, per‑turn sequence for ordering within a submission id.
/// Resets to 0 at TaskStarted and increments for each subsequent event.
pub event_seq: u64,
/// Payload
pub msg: EventMsg,
/// Optional model-provided ordering metadata (when applicable)
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<OrderMeta>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OrderMeta {
/// 1-based ordinal of this request/turn in the session
pub request_ordinal: u64,
/// Model-provided output_index for the top-level item
#[serde(skip_serializing_if = "Option::is_none")]
pub output_index: Option<u32>,
/// Model-provided sequence_number within the output_index stream
#[serde(skip_serializing_if = "Option::is_none")]
pub sequence_number: Option<u64>,
}
/// Response event from the agent
@@ -877,6 +895,7 @@ mod tests {
let session_id: Uuid = uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8");
let event = Event {
id: "1234".to_string(),
event_seq: 0,
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
model: "codex-mini-latest".to_string(),
@@ -887,7 +906,7 @@ mod tests {
let serialized = serde_json::to_string(&event).unwrap();
assert_eq!(
serialized,
r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
r#"{"id":"1234","event_seq":0,"msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
);
}
}
@@ -166,7 +166,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
fn process_event(&mut self, event: Event) -> CodexStatus {
let Event { id: _, msg } = event;
let Event { id: _, msg, .. } = event;
match msg {
EventMsg::Error(ErrorEvent { message }) => {
let prefix = "ERROR:".style(self.red);
@@ -498,7 +498,7 @@ async fn apply_bespoke_event_handling(
outgoing: Arc<OutgoingMessageSender>,
_pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
) {
let Event { id: event_id, msg } = event;
let Event { id: event_id, msg, .. } = event;
match msg {
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
@@ -69,7 +69,9 @@ pub async fn run_codex_tool_session(
let session_configured_event = Event {
// Use a fake id value for now.
id: "".to_string(),
event_seq: 0,
msg: EventMsg::SessionConfigured(session_configured.clone()),
order: None,
};
outgoing
.send_event_as_notification(
+2
View File
@@ -16,6 +16,8 @@ path = "src/lib.rs"
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
# Disable legacy tests by default; enable with `--features legacy_tests` if needed.
legacy_tests = []
[lints]
workspace = true
+17 -54
View File
@@ -81,6 +81,8 @@ pub(crate) struct App<'a> {
/// Debug flag for logging LLM requests/responses
_debug: bool,
/// Show per-cell ordering overlay when true
show_order_overlay: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
@@ -106,6 +108,7 @@ pub(crate) struct ChatWidgetArgs {
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
terminal_info: TerminalInfo,
show_order_overlay: bool,
}
impl App<'_> {
@@ -115,6 +118,7 @@ impl App<'_> {
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
debug: bool,
show_order_overlay: bool,
terminal_info: TerminalInfo,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::new(AuthManager::shared(
@@ -195,6 +199,7 @@ impl App<'_> {
initial_images,
enhanced_keys_supported,
terminal_info: terminal_info.clone(),
show_order_overlay,
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
@@ -215,6 +220,7 @@ impl App<'_> {
initial_images,
enhanced_keys_supported,
terminal_info.clone(),
show_order_overlay,
);
// Check for initial animations after widget is created
chat_widget.check_for_initial_animations();
@@ -238,6 +244,7 @@ impl App<'_> {
_transcript_saved_viewport: None,
enhanced_keys_supported,
_debug: debug,
show_order_overlay,
commit_anim_running: Arc::new(AtomicBool::new(false)),
terminal_info,
clear_on_first_frame: true,
@@ -597,6 +604,7 @@ impl App<'_> {
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
));
self.app_state = AppState::Chat { widget: new_widget };
}
@@ -715,6 +723,7 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
event_seq: 0,
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
@@ -743,6 +752,7 @@ impl App<'_> {
grant_root: Some(PathBuf::from("/tmp")),
},
),
order: None,
}));
}
}
@@ -759,6 +769,7 @@ impl App<'_> {
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -861,6 +872,7 @@ impl App<'_> {
initial_images,
initial_prompt,
terminal_info,
show_order_overlay,
}) => {
self.app_state = AppState::Chat {
widget: Box::new(ChatWidget::new(
@@ -870,6 +882,7 @@ impl App<'_> {
initial_images,
enhanced_keys_supported,
terminal_info,
show_order_overlay,
)),
}
}
@@ -947,6 +960,7 @@ impl App<'_> {
self.app_event_tx.clone(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
@@ -954,9 +968,11 @@ impl App<'_> {
// Replay prefix to the UI
let ev = codex_core::protocol::Event {
id: "fork".to_string(),
event_seq: 0,
msg: codex_core::protocol::EventMsg::ReplayHistory(
codex_core::protocol::ReplayHistoryEvent { items: prefix_items }
),
order: None,
};
self.app_event_tx.send(AppEvent::CodexEvent(ev));
@@ -1101,57 +1117,4 @@ fn should_show_login_screen(login_status: crate::LoginStatus, _config: &Config)
matches!(login_status, crate::LoginStatus::NotAuthenticated)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_login::AuthMode;
fn make_config(preferred: AuthMode) -> Config {
let mut cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
std::env::temp_dir(),
)
.expect("load default config");
cfg.preferred_auth_method = preferred;
cfg
}
#[test]
fn shows_login_when_not_authenticated() {
let cfg = make_config(AuthMode::ChatGPT);
assert!(should_show_login_screen(
LoginStatus::NotAuthenticated,
&cfg
));
}
#[test]
fn shows_login_when_api_key_but_prefers_chatgpt() {
let cfg = make_config(AuthMode::ChatGPT);
assert!(should_show_login_screen(
LoginStatus::AuthMode(AuthMode::ApiKey),
&cfg
))
}
#[test]
fn hides_login_when_api_key_and_prefers_api_key() {
let cfg = make_config(AuthMode::ApiKey);
assert!(!should_show_login_screen(
LoginStatus::AuthMode(AuthMode::ApiKey),
&cfg
))
}
#[test]
fn hides_login_when_chatgpt_and_prefers_chatgpt() {
let cfg = make_config(AuthMode::ChatGPT);
assert!(!should_show_login_screen(
LoginStatus::AuthMode(AuthMode::ChatGPT),
&cfg
))
}
}
// (legacy tests removed)
+1 -1
View File
@@ -110,7 +110,7 @@ fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize,
(header_idx, end)
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use ratatui::text::Span;
@@ -71,7 +71,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use crate::app_event::AppEvent;
@@ -1614,7 +1614,7 @@ impl WidgetRef for ChatComposer {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use crate::app_event::AppEvent;
use crate::bottom_pane::AppEventSender;
@@ -237,7 +237,7 @@ impl ChatComposerHistory {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
#![expect(clippy::expect_used)]
use super::*;
@@ -29,7 +29,7 @@ pub(crate) struct CommandPopup {
}
impl CommandPopup {
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
pub(crate) fn new() -> Self {
Self::new_with_filter(false)
}
@@ -230,7 +230,7 @@ impl WidgetRef for CommandPopup {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
@@ -11,7 +11,7 @@ pub(crate) struct LiveRingWidget {
}
impl LiveRingWidget {
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
pub fn new(max_rows: usize, rows: Vec<Line<'static>>) -> Self {
Self {
max_rows: max_rows as u16,
+3 -7
View File
@@ -541,11 +541,7 @@ impl BottomPane<'_> {
self.live_ring = None;
}
/// Set the live ring rows and maximum rows to display
#[cfg(test)]
pub(crate) fn set_live_ring_rows(&mut self, max_rows: usize, rows: Vec<Line<'static>>) {
self.live_ring = Some(live_ring_widget::LiveRingWidget::new(max_rows, rows));
}
// test helper removed
/// Ensure input focus is maintained, especially after redraws or content updates
pub(crate) fn ensure_input_focus(&mut self) {
@@ -651,8 +647,8 @@ impl WidgetRef for &BottomPane<'_> {
}
}
#[cfg(test)]
mod tests {
#[cfg(all(test, feature = "legacy_tests"))]
mod tests_removed {
use super::*;
use crate::app_event::AppEvent;
use ratatui::buffer::Buffer;
+1 -1
View File
@@ -84,7 +84,7 @@ impl ScrollState {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::ScrollState;
+1 -1
View File
@@ -809,7 +809,7 @@ impl TextArea {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
+52 -38
View File
@@ -317,15 +317,52 @@ pub(super) fn handle_exec_begin_now(chat: &mut ChatWidget<'_>, ev: ExecCommandBe
.is_some() => Some(idx),
_ => None,
};
let idx = if let Some(i) = agg_index {
i
} else {
let before_len = chat.history_cells.len();
chat.history_push(history_cell::ReadAggregationCell::new());
let i = if chat.history_cells.len() > 0 { chat.history_cells.len() - 1 } else { before_len };
let idx = if let Some(i) = agg_index { i } else {
// Reserve an ordered slot for the read aggregation header if the provider
// supplied OrderMeta; otherwise it will fall back to unordered.
let i = chat.push_cell_maybe_ordered(history_cell::ReadAggregationCell::new());
// If the immediately-previous cell is also a ReadAggregationCell (from an
// earlier attempt), merge into it so consecutive Read blocks collapse into
// a single "Read" section as per UX rules.
if i > 0 {
let prev_is_read_agg = chat.history_cells[i - 1]
.as_any()
.downcast_ref::<history_cell::ReadAggregationCell>()
.is_some();
if prev_is_read_agg {
// Move this new cell's lines into the previous aggregator, then remove self.
if let Some(new_cell) = chat.history_cells[i]
.as_any_mut()
.downcast_mut::<history_cell::ReadAggregationCell>()
{
let lines = new_cell.display_lines();
// drop header and push body lines only
let mut body: Vec<ratatui::text::Line<'static>> = lines.into_iter().skip(1).collect();
if let Some(prev_cell) = chat.history_cells[i - 1]
.as_any_mut()
.downcast_mut::<history_cell::ReadAggregationCell>()
{
prev_cell.push_lines(body.drain(..).collect());
}
}
chat.history_remove_at(i);
chat.invalidate_height_cache();
chat.autoscroll_if_near_bottom();
chat.bottom_pane.set_has_chat_history(true);
chat.process_animation_cleanup();
chat.app_event_tx.send(AppEvent::RequestRedraw);
// Use the previous cell as the active aggregator
chat.exec.running_read_agg_index = Some(i - 1);
i - 1
} else {
chat.exec.running_read_agg_index = Some(i);
i
}
} else {
chat.exec.running_read_agg_index = Some(i);
i
};
}
};
let tmp = history_cell::new_active_exec_command(ev.command.clone(), parsed_command.clone());
let mut lines = tmp.display_lines();
if !lines.is_empty() { lines.remove(0); }
@@ -342,9 +379,7 @@ pub(super) fn handle_exec_begin_now(chat: &mut ChatWidget<'_>, ev: ExecCommandBe
}
let cell = history_cell::new_active_exec_command(ev.command.clone(), parsed_command.clone());
let before_len = chat.history_cells.len();
chat.history_push(cell);
let idx = if chat.history_cells.len() > 0 { chat.history_cells.len() - 1 } else { before_len };
let idx = chat.push_cell_maybe_ordered(cell);
chat.exec.running_commands.insert(
super::ExecCallId(ev.call_id.clone()),
super::RunningCommand { command: ev.command.clone(), parsed: parsed_command, history_index: Some(idx) },
@@ -448,7 +483,12 @@ pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndE
}
if !replaced {
if let Some(c) = completed_opt.take() { chat.history_push_and_maybe_merge(c); }
if let Some(c) = completed_opt.take() {
// Respect any pending ordered key captured from OrderMeta for this event.
let idx = chat.push_cell_maybe_ordered(c);
// Attempt standard merge with previous Exec if applicable.
crate::chatwidget::exec_tools::try_merge_completed_exec_at(chat, idx);
}
}
if exit_code == 0 {
@@ -459,33 +499,7 @@ pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndE
chat.maybe_hide_spinner();
}
pub(super) fn maybe_move_last_before_final_assistant_exec(chat: &mut ChatWidget<'_>, call_id: &str) {
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
if chat.history_cells.is_empty() { return; }
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
if let Some(rc) = chat.exec.running_commands.get_mut(&super::ExecCallId(call_id.to_string())) { rc.history_index = Some(assistant_idx); }
chat.invalidate_height_cache(); chat.request_redraw();
}
pub(super) fn maybe_move_last_before_final_assistant_tool(chat: &mut ChatWidget<'_>, call_id: &str) {
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
if chat.history_cells.is_empty() { return; }
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
if let Some(idx) = chat.tools_state.running_custom_tools.get_mut(&super::ToolCallId(call_id.to_string())) { *idx = assistant_idx; }
chat.invalidate_height_cache(); chat.request_redraw();
}
pub(super) fn maybe_move_last_before_final_assistant(chat: &mut ChatWidget<'_>, seq: u64) {
let ans_seq = match chat.stream_state.seq_answer_final { Some(s) => s, None => return };
if seq >= ans_seq { return; }
let assistant_idx = match chat.index_of_final_assistant() { Some(i) => i, None => return };
if chat.history_cells.is_empty() { return; }
let last_idx = chat.history_cells.len() - 1; if last_idx <= assistant_idx { return; }
let cell = chat.history_cells.remove(last_idx); chat.history_cells.insert(assistant_idx, cell);
chat.invalidate_height_cache(); chat.request_redraw();
}
// Stable ordering now inserts at the correct position; these helpers are removed.
// `handle_exec_approval_now` remains on ChatWidget in chatwidget.rs because
// it is referenced directly from interrupt handling and is trivial.
+56 -35
View File
@@ -15,13 +15,13 @@ use super::tools;
#[derive(Debug)]
pub(crate) enum QueuedInterrupt {
ExecApproval { seq: u64, id: String, ev: ExecApprovalRequestEvent },
ApplyPatchApproval { seq: u64, id: String, ev: ApplyPatchApprovalRequestEvent },
ExecBegin { seq: u64, ev: ExecCommandBeginEvent },
ExecEnd { seq: u64, ev: ExecCommandEndEvent },
McpBegin { seq: u64, ev: McpToolCallBeginEvent },
McpEnd { seq: u64, ev: McpToolCallEndEvent },
PatchEnd { seq: u64, ev: PatchApplyEndEvent },
PlanUpdate { seq: u64, ev: UpdatePlanArgs },
ApplyPatchApproval { seq: u64, id: String, ev: ApplyPatchApprovalRequestEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize> },
ExecBegin { seq: u64, ev: ExecCommandBeginEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>, turn_id: Option<String> },
ExecEnd { seq: u64, ev: ExecCommandEndEvent, turn_win: Option<usize> },
McpBegin { seq: u64, ev: McpToolCallBeginEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>, turn_id: Option<String> },
McpEnd { seq: u64, ev: McpToolCallEndEvent, turn_win: Option<usize> },
PatchEnd { seq: u64, ev: PatchApplyEndEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize> },
PlanUpdate { seq: u64, ev: UpdatePlanArgs, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize> },
}
#[derive(Default)]
@@ -46,32 +46,34 @@ impl InterruptManager {
seq: u64,
id: String,
ev: ApplyPatchApprovalRequestEvent,
order: Option<codex_core::protocol::OrderMeta>,
turn_win: Option<usize>,
) {
self.queue.push(QueuedInterrupt::ApplyPatchApproval { seq, id, ev });
self.queue.push(QueuedInterrupt::ApplyPatchApproval { seq, id, ev, order, turn_win });
}
pub(crate) fn push_exec_begin(&mut self, seq: u64, ev: ExecCommandBeginEvent) {
self.queue.push(QueuedInterrupt::ExecBegin { seq, ev });
pub(crate) fn push_exec_begin(&mut self, seq: u64, ev: ExecCommandBeginEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>, turn_id: Option<String>) {
self.queue.push(QueuedInterrupt::ExecBegin { seq, ev, order, turn_win, turn_id });
}
pub(crate) fn push_exec_end(&mut self, seq: u64, ev: ExecCommandEndEvent) {
self.queue.push(QueuedInterrupt::ExecEnd { seq, ev });
pub(crate) fn push_exec_end(&mut self, seq: u64, ev: ExecCommandEndEvent, turn_win: Option<usize>) {
self.queue.push(QueuedInterrupt::ExecEnd { seq, ev, turn_win });
}
pub(crate) fn push_mcp_begin(&mut self, seq: u64, ev: McpToolCallBeginEvent) {
self.queue.push(QueuedInterrupt::McpBegin { seq, ev });
pub(crate) fn push_mcp_begin(&mut self, seq: u64, ev: McpToolCallBeginEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>, turn_id: Option<String>) {
self.queue.push(QueuedInterrupt::McpBegin { seq, ev, order, turn_win, turn_id });
}
pub(crate) fn push_mcp_end(&mut self, seq: u64, ev: McpToolCallEndEvent) {
self.queue.push(QueuedInterrupt::McpEnd { seq, ev });
pub(crate) fn push_mcp_end(&mut self, seq: u64, ev: McpToolCallEndEvent, turn_win: Option<usize>) {
self.queue.push(QueuedInterrupt::McpEnd { seq, ev, turn_win });
}
pub(crate) fn push_patch_end(&mut self, seq: u64, ev: PatchApplyEndEvent) {
self.queue.push(QueuedInterrupt::PatchEnd { seq, ev });
pub(crate) fn push_patch_end(&mut self, seq: u64, ev: PatchApplyEndEvent, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>) {
self.queue.push(QueuedInterrupt::PatchEnd { seq, ev, order, turn_win });
}
pub(crate) fn push_plan_update(&mut self, seq: u64, ev: UpdatePlanArgs) {
self.queue.push(QueuedInterrupt::PlanUpdate { seq, ev });
pub(crate) fn push_plan_update(&mut self, seq: u64, ev: UpdatePlanArgs, order: Option<codex_core::protocol::OrderMeta>, turn_win: Option<usize>) {
self.queue.push(QueuedInterrupt::PlanUpdate { seq, ev, order, turn_win });
}
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
@@ -80,29 +82,48 @@ impl InterruptManager {
for q in self.queue.drain(..) {
match q {
QueuedInterrupt::ExecApproval { id, ev, .. } => chat.handle_exec_approval_now(id, ev),
QueuedInterrupt::ApplyPatchApproval { seq, id, ev } => {
QueuedInterrupt::ApplyPatchApproval { seq, id, ev, order, turn_win } => {
let ok = super::ChatWidget::order_key_from_order_meta(order.as_ref())
.unwrap_or_else(|| chat.next_unordered_seq());
tracing::info!("[order/flush] ApplyPatchApproval: queue_seq={} chosen_key={}", seq, ok.0);
chat.set_pending_insert_seq(ok.0);
if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); }
chat.handle_apply_patch_approval_now(id, ev);
chat.maybe_move_last_before_final_assistant(seq);
}
QueuedInterrupt::ExecBegin { ev, .. } => {
let call_id = ev.call_id.clone();
QueuedInterrupt::ExecBegin { seq, ev, order, turn_win, turn_id } => {
let ok = super::ChatWidget::order_key_from_order_meta(order.as_ref())
.unwrap_or_else(|| chat.next_unordered_seq());
tracing::info!("[order/flush] ExecBegin: queue_seq={} chosen_key={}", seq, ok.0);
chat.set_pending_insert_seq(ok.0);
if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); }
if let Some(tid) = turn_id { chat.call_id_to_turn.insert(ev.call_id.clone(), tid); }
chat.handle_exec_begin_now(ev);
chat.maybe_move_last_before_final_assistant_exec(&call_id);
}
QueuedInterrupt::ExecEnd { ev, .. } => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin { ev, .. } => {
let call_id = ev.call_id.clone();
QueuedInterrupt::ExecEnd { ev, turn_win, .. } => { if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); } chat.handle_exec_end_now(ev) },
QueuedInterrupt::McpBegin { seq, ev, order, turn_win, turn_id } => {
let ok = super::ChatWidget::order_key_from_order_meta(order.as_ref())
.unwrap_or_else(|| chat.next_unordered_seq());
tracing::info!("[order/flush] McpBegin: queue_seq={} chosen_key={}", seq, ok.0);
chat.set_pending_insert_seq(ok.0);
if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); }
if let Some(tid) = turn_id { chat.call_id_to_turn.insert(ev.call_id.clone(), tid); }
tools::mcp_begin(chat, ev);
chat.maybe_move_last_before_final_assistant_tool(&call_id);
}
QueuedInterrupt::McpEnd { ev, .. } => tools::mcp_end(chat, ev),
QueuedInterrupt::PatchEnd { seq, ev, .. } => {
QueuedInterrupt::McpEnd { ev, turn_win, .. } => { if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); } tools::mcp_end(chat, ev) },
QueuedInterrupt::PatchEnd { seq, ev, order, turn_win } => {
let ok = super::ChatWidget::order_key_from_order_meta(order.as_ref())
.unwrap_or_else(|| chat.next_unordered_seq());
tracing::info!("[order/flush] PatchEnd: queue_seq={} chosen_key={}", seq, ok.0);
chat.set_pending_insert_seq(ok.0);
if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); }
chat.handle_patch_apply_end_now(ev);
chat.maybe_move_last_before_final_assistant(seq);
}
QueuedInterrupt::PlanUpdate { seq, ev, .. } => {
chat.history_push(crate::history_cell::new_plan_update(ev));
chat.maybe_move_last_before_final_assistant(seq);
QueuedInterrupt::PlanUpdate { seq, ev, order, turn_win } => {
let ok = super::ChatWidget::order_key_from_order_meta(order.as_ref())
.unwrap_or_else(|| chat.next_unordered_seq());
tracing::info!("[order/flush] PlanUpdate: queue_seq={} chosen_key={}", seq, ok.0);
if let Some(wi) = turn_win { chat.pending_insert_turn = Some(wi); }
chat.history_insert_with_seq(crate::history_cell::new_plan_update(ev), ok.0);
}
}
}
+1
View File
@@ -44,6 +44,7 @@ pub(super) fn delta_text(chat: &mut ChatWidget<'_>, kind: StreamKind, id: String
chat.stream_state.current_kind = Some(kind);
let sink = AppEventHistorySink(chat.app_event_tx.clone());
chat.stream.begin_with_id(kind, Some(id), &sink);
chat.stream.push_and_maybe_commit(&delta, &sink);
}
+10 -8
View File
@@ -10,10 +10,11 @@ pub(super) fn web_search_begin(chat: &mut ChatWidget<'_>, call_id: String, query
chat.flush_interrupt_queue();
let cell = history_cell::new_running_web_search(query.clone());
chat.history_push(cell);
if let Some(last_idx) = chat.history_cells.len().checked_sub(1) {
chat.tools_state.running_web_search.insert(super::ToolCallId(call_id), (last_idx, query));
}
let idx = chat.push_cell_maybe_ordered(cell);
tracing::info!("[order] WebSearchBegin call_id={} idx={}", call_id, idx);
chat.tools_state
.running_web_search
.insert(super::ToolCallId(call_id), (idx, query));
chat.bottom_pane.update_status_text("Searched".to_string());
chat.mark_needs_redraw();
}
@@ -50,6 +51,7 @@ pub(super) fn web_search_complete(chat: &mut ChatWidget<'_>, call_id: String, qu
let completed = rt.finalize_web_search(true, final_query);
chat.history_replace_at(i, Box::new(completed));
chat.history_maybe_merge_tool_with_previous(i);
tracing::info!("[order] WebSearchEnd replace at idx={}", i);
}
}
}
@@ -61,10 +63,10 @@ pub(super) fn mcp_begin(chat: &mut ChatWidget<'_>, ev: McpToolCallBeginEvent) {
for cell in &chat.history_cells { cell.trigger_fade(); }
let McpToolCallBeginEvent { call_id, invocation } = ev;
let cell = history_cell::new_running_mcp_tool_call(invocation);
chat.history_push(cell);
if let Some(last_idx) = chat.history_cells.len().checked_sub(1) {
chat.tools_state.running_custom_tools.insert(super::ToolCallId(call_id), last_idx);
}
let idx = chat.push_cell_maybe_ordered(cell);
chat.tools_state
.running_custom_tools
.insert(super::ToolCallId(call_id), idx);
}
pub(super) fn mcp_end(chat: &mut ChatWidget<'_>, ev: McpToolCallEndEvent) {
+5
View File
@@ -65,6 +65,11 @@ pub struct Cli {
#[clap(long = "debug", short = 'd', default_value_t = false)]
pub debug: bool,
/// Show per-cell ordering overlays (request index, order key, window/position) to debug
/// event ordering. Off by default.
#[arg(long = "order", default_value_t = false)]
pub order: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+1 -1
View File
@@ -212,7 +212,7 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
// Image format inference removed alongside clipboard image helpers.
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod pasted_paths_tests {
use super::*;
+1 -1
View File
@@ -583,7 +583,7 @@ fn error_tint() -> Color { tinted_bg_toward(crate::colors::error()) }
// Removed per-line tinted backgrounds per design feedback
#[allow(clippy::expect_used)]
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use insta::assert_snapshot;
+1 -1
View File
@@ -42,7 +42,7 @@ where
None
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
+34 -43
View File
@@ -366,9 +366,7 @@ impl ReadAggregationCell {
self.lines.extend(more);
}
pub(crate) fn finalize(&mut self) {
self.finalized = true;
}
pub(crate) fn finalize(&mut self) { self.finalized = true; }
// Build a normalized copy of aggregated lines where only the very first
// visible line uses the corner connector "└ "; subsequent lines use two
@@ -401,17 +399,11 @@ impl ReadAggregationCell {
impl HistoryCell for ReadAggregationCell {
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType {
HistoryCellType::Exec { kind: ExecKind::Read, status: if self.finalized { ExecStatus::Success } else { ExecStatus::Running } }
}
fn kind(&self) -> HistoryCellType { HistoryCellType::Exec { kind: ExecKind::Read, status: if self.finalized { ExecStatus::Success } else { ExecStatus::Running } } }
fn display_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
// Header: match Exec running/success styling for informational actions
let header = if self.finalized {
Line::styled("Read", Style::default().fg(crate::colors::text()))
} else {
Line::styled("Read", Style::default().fg(crate::colors::info()))
};
// Always render a stable, completed-style header to avoid flicker
let header = Line::styled("Read", Style::default().fg(crate::colors::text()));
out.push(header);
out.extend(self.normalized_lines());
out
@@ -1435,10 +1427,10 @@ mod tests {
#[test]
fn action_enum_from_parsed_variants() {
// Read
let parsed = vec![ParsedCommand::Read { name: "foo.txt".into(), cmd: "sed -n '1,10p' foo.txt".into(), path: Some("foo.txt".into()) }];
let parsed = vec![ParsedCommand::Read { name: "foo.txt".into(), cmd: "sed -n '1,10p' foo.txt".into() }];
assert!(matches!(action_enum_from_parsed(&parsed), ExecAction::Read));
// Search
let parsed = vec![ParsedCommand::Search { query: "term".into(), path: Some("src".into()), cmd: "rg term src".into() }];
let parsed = vec![ParsedCommand::Search { query: Some("term".into()), path: Some("src".into()), cmd: "rg term src".into() }];
assert!(matches!(action_enum_from_parsed(&parsed), ExecAction::Search));
// List files
let parsed = vec![ParsedCommand::ListFiles { cmd: "ls -la".into(), path: Some(".".into()) }];
@@ -1454,7 +1446,7 @@ mod tests {
#[test]
fn merged_exec_cell_push_and_kind() {
// Build two completed ExecCell instances for Read
let parsed = vec![ParsedCommand::Read { name: "foo.txt".into(), cmd: "sed -n '1,10p' foo.txt".into(), path: Some("foo.txt".into()) }];
let parsed = vec![ParsedCommand::Read { name: "foo.txt".into(), cmd: "sed -n '1,10p' foo.txt".into() }];
let e1 = new_completed_exec_command(
vec!["sed".into(), "-n".into(), "1,10p".into(), "foo.txt".into()],
parsed.clone(),
@@ -1989,33 +1981,27 @@ fn exec_render_parts_parsed(
let ctx_path = first_context_path(parsed_commands);
let mut pre: Vec<Line<'static>> = vec![match output {
None => {
let duration_str = if let Some(start) = start_time {
let elapsed = start.elapsed();
format!(" ({})", format_duration(elapsed))
} else {
String::new()
};
let header = match action {
ExecAction::Read => "Read".to_string(),
ExecAction::Search => "Searched".to_string(),
ExecAction::List => "List Files".to_string(),
ExecAction::Run => match &ctx_path {
Some(p) => format!("Running... in {}", p),
None => "Running...".to_string(),
},
};
if matches!(action, ExecAction::Read | ExecAction::Search | ExecAction::List) {
Line::styled(
header + &duration_str,
Style::default().fg(crate::colors::info()),
)
} else {
Line::styled(
header + &duration_str,
Style::default()
.fg(crate::colors::info())
.add_modifier(Modifier::BOLD),
)
match action {
// For these informational actions, remove the transient “running” style
// and render exactly like the finalized state (no duration, normal text color).
ExecAction::Read => Line::styled("Read", Style::default().fg(crate::colors::text())),
ExecAction::Search => Line::styled("Searched", Style::default().fg(crate::colors::text())),
ExecAction::List => Line::styled("List Files", Style::default().fg(crate::colors::text())),
// Keep rich running header for real Run commands
ExecAction::Run => {
let duration_str = if let Some(start) = start_time {
let elapsed = start.elapsed();
format!(" ({})", format_duration(elapsed))
} else { String::new() };
let header = match &ctx_path {
Some(p) => format!("Running... in {}", p),
None => "Running...".to_string(),
};
Line::styled(
header + &duration_str,
Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD),
)
}
}
}
Some(o) if o.exit_code == 0 => {
@@ -2802,14 +2788,17 @@ pub(crate) struct CollapsibleReasoningCell {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) collapsed: std::cell::Cell<bool>,
pub(crate) in_progress: std::cell::Cell<bool>,
// Optional stream id to anchor routing of deltas/finals
pub(crate) id: Option<String>,
}
impl CollapsibleReasoningCell {
pub fn new(lines: Vec<Line<'static>>) -> Self {
pub fn new_with_id(lines: Vec<Line<'static>>, id: Option<String>) -> Self {
Self {
lines,
collapsed: std::cell::Cell::new(true), // Default to collapsed
in_progress: std::cell::Cell::new(false),
id,
}
}
@@ -2961,6 +2950,8 @@ impl HistoryCell for CollapsibleReasoningCell {
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Touch id for sanity/read to avoid dead-code warning; used for routing in ChatWidget
let _ = &self.id;
if self.lines.is_empty() {
return Vec::new();
}
+1 -1
View File
@@ -483,7 +483,7 @@ fn slice_line_spans(
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
+3 -1
View File
@@ -73,7 +73,7 @@ mod status_indicator_widget;
// Internal vt100-based replay tests live as a separate source file to keep them
// close to the widget code. Include them in unit tests.
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod chatwidget_stream_tests;
#[cfg(not(debug_assertions))]
@@ -343,6 +343,7 @@ fn run_ratatui_app(
prompt,
images,
debug,
order,
..
} = cli;
let mut app = App::new(
@@ -351,6 +352,7 @@ fn run_ratatui_app(
images,
should_show_trust_screen,
debug,
order,
terminal_info,
);
+1 -1
View File
@@ -206,7 +206,7 @@ pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize
(prefix, suffix, cols)
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
+1 -1
View File
@@ -355,7 +355,7 @@ fn split_text_and_fences(src: &str) -> Vec<Segment> {
segments
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
+2 -2
View File
@@ -1700,7 +1700,7 @@ fn to_superscript(s: &str) -> String {
.collect()
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use ratatui::style::Modifier;
@@ -1757,7 +1757,7 @@ mod tests {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod fenced_padding_tests {
use super::*;
use unicode_width::UnicodeWidthStr;
+29 -7
View File
@@ -15,6 +15,9 @@ pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_line_count: usize,
bold_first_sentence: bool,
// When true, insert an extra newline after the next natural newline
// boundary to force a section separation without cutting a word mid‑line.
pending_section_break: bool,
}
impl MarkdownStreamCollector {
@@ -23,6 +26,7 @@ impl MarkdownStreamCollector {
buffer: String::new(),
committed_line_count: 0,
bold_first_sentence: false,
pending_section_break: false,
}
}
@@ -31,6 +35,7 @@ impl MarkdownStreamCollector {
buffer: String::new(),
committed_line_count: 0,
bold_first_sentence: true,
pending_section_break: false,
}
}
@@ -48,6 +53,7 @@ impl MarkdownStreamCollector {
self.buffer.clear();
self.committed_line_count = 0;
// Keep bold_first_sentence setting
self.pending_section_break = false;
}
/// Replace the buffered content and mark that the first `committed_count`
@@ -56,10 +62,24 @@ impl MarkdownStreamCollector {
self.buffer.clear();
self.buffer.push_str(s);
self.committed_line_count = committed_count;
// A full replace cancels any pending break; the new content can include
// its own spacing.
self.pending_section_break = false;
}
pub fn push_delta(&mut self, delta: &str) {
self.buffer.push_str(delta);
// If we were asked to insert a section break but the buffer didn't end
// with a newline at the time, defer adding the extra newline until we
// naturally hit a newline boundary via streaming. This prevents cutting
// a word mid‑line (observed as missing syllables like "Summarizing" → "Summizing").
if self.pending_section_break && self.buffer.ends_with('\n') {
// Ensure exactly one extra blank line (i.e., double newline total)
if !self.buffer.ends_with("\n\n") {
self.buffer.push('\n');
}
self.pending_section_break = false;
}
}
/// Insert a paragraph/section separator if one is not already present at the
@@ -68,13 +88,15 @@ impl MarkdownStreamCollector {
if self.buffer.is_empty() {
return;
}
// For reasoning content, ensure we have a double newline for section separation
if !self.buffer.ends_with("\n\n") {
if self.buffer.ends_with('\n') {
// If we're already at a line boundary, append one more newline. Otherwise,
// defer the extra newline until push_delta hits a natural boundary.
if self.buffer.ends_with('\n') {
if !self.buffer.ends_with("\n\n") {
self.buffer.push('\n');
} else {
self.buffer.push_str("\n\n");
}
self.pending_section_break = false;
} else {
self.pending_section_break = true;
}
}
@@ -405,7 +427,7 @@ impl AnimatedLineStreamer {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
@@ -425,7 +447,7 @@ pub(crate) fn simulate_stream_markdown_for_tests(
out
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use codex_core::config::Config;
+1 -1
View File
@@ -495,7 +495,7 @@ impl StaticOverlay {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use insta::assert_snapshot;
+1 -1
View File
@@ -139,7 +139,7 @@ impl WidgetRef for StatusIndicatorWidget {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use crate::app_event::AppEvent;
+11 -1
View File
@@ -113,6 +113,16 @@ impl StreamController {
// Removed - we don't need to add extra blank lines
// The markdown renderer and section breaks already handle spacing
}
/// Get the current stream kind being processed
pub(crate) fn current_stream(&self) -> Option<StreamKind> {
self.current_stream
}
/// Get the current stream ID
pub(crate) fn current_stream_id(&self) -> Option<&String> {
self.current_stream_id.as_ref()
}
/// Begin a stream, flushing previously completed lines from any other
/// active stream to maintain ordering.
@@ -580,7 +590,7 @@ impl StreamController {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use codex_core::config::Config;
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::markdown_stream::AnimatedLineStreamer;
use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod controller;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum StreamKind {
Answer,
Reasoning,
+1 -1
View File
@@ -102,7 +102,7 @@ pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
+1 -1
View File
@@ -128,7 +128,7 @@ fn parse_markdown_line(text: &str) -> Line<'static> {
Line::from(spans)
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
+1 -1
View File
@@ -116,7 +116,7 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
Some((maj, min, pat))
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
+1 -1
View File
@@ -392,7 +392,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use crossterm::event::KeyCode;
-3
View File
@@ -1,3 +0,0 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
File diff suppressed because one or more lines are too long
-298
View File
@@ -1,298 +0,0 @@
thinking
Investigating binary sizes
I need to check the codex-rs repository to explain why the project's binaries
are large. The user is likely seeking specifics about the setup: are Rust builds
static, what features are enabled, and is debug information included? It could
be due to static linking, included OpenSSL, or how panic handling is set up. I
should look into the Cargo.toml file to confirm features and profiles without
needing to edit any code. Let's get started on this!
codex
I’m going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then I’ll summarize the main causes.
⚡ Running ls -la
└ total 6696
drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 .
drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 ..
-rw-r--r--@ 1 easong staff 10244 Aug 8 21:13 .DS_Store
-rw-r--r--@ 1 easong staff 212 Aug 4 18:49 .gitignore
… +30 lines
-rw-r--r--@ 1 easong staff 9971 Aug 8 23:07 simpler.md
drwxr-xr-x@ 6 easong staff 192 Aug 4 22:40 target
drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui
-rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md
-rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md
⚡ Running sed -n '1,200p' Cargo.toml
└ [workspace]
members = [
"ansi-escape",
"apply-patch",
"arg0",
… +38 lines
codegen-units = 1
[patch.crates-io]
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
"nornagon-v0.29.0-patch" }
Optimizing Rust Release Profile
I'm reviewing the workspace's release profile, which has settings like lto=fat,
strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that
"strip = symbols" only removes symbols, not debug information. In Rust 1.73+,
both "symbols" and "debuginfo" stripping options are available. I should check
whether using these results in a smaller binary. I’ll also skim through crates
for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox,
tui, login, ollama, and mcp.
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
└ --- ansi-escape/Cargo.toml
[package]
edition = "2024"
name = "codex-ansi-escape"
version = { workspace = true }
… +237 lines
"process",
"rt-multi-thread",
"signal",
] }
tracing = { version
⚡ Running sed -n '1,200p' execpolicy/Cargo.toml
└ [package]
name = "codex-execpolicy"
version = { workspace = true }
edition = "2024"
… +23 lines
serde_json = "1.0.142"
serde_with = { version = "3", features = ["macros"] }
[dev-dependencies]
tempfile = "3.13.0"
⚡ Running sed -n '1,200p' file-search/Cargo.toml
└ [package]
edition = "2024"
name = "codex-file-search"
version = { workspace = true }
… +11 lines
ignore = "0.4.23"
nucleo-matcher = "0.3.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.142"
tokio = { version = "1", features = ["full"] }
⚡ Running sed -n '1,200p' linux-sandbox/Cargo.toml
└ [package]
edition = "2024"
name = "codex-linux-sandbox"
version = { workspace = true }
… +24 lines
"macros",
"process",
"rt-multi-thread",
"signal",
] }
⚡ Running sed -n '1,200p' login/Cargo.toml
└ [package]
edition = "2024"
name = "codex-login"
version = { workspace = true }
… +17 lines
] }
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"
⚡ Running sed -n '1,200p' mcp-client/Cargo.toml
└ [package]
name = "codex-mcp-client"
version = { workspace = true }
edition = "2024"
… +13 lines
"process",
"rt-multi-thread",
"sync",
"time",
] }
⚡ Running sed -n '1,200p' mcp-server/Cargo.toml
└ [package]
edition = "2024"
name = "codex-mcp-server"
version = { workspace = true }
… +35 lines
mcp_test_support = { path = "tests/common" }
pretty_assertions = "1.4.1"
tempfile = "3"
tokio-test = "0.4"
wiremock = "0.6"
⚡ Running sed -n '1,200p' mcp-types/Cargo.toml
└ [package]
edition = "2024"
name = "mcp-types"
version = { workspace = true }
… +1 lines
workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
⚡ Running sed -n '1,200p' ollama/Cargo.toml
└ [package]
edition = "2024"
name = "codex-ollama"
version = { workspace = true }
… +22 lines
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
[dev-dependencies]
tempfile = "3"
⚡ Running sed -n '1,200p' tui/Cargo.toml
└ [package]
edition = "2024"
name = "codex-tui"
version = { workspace = true }
… +74 lines
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
vt100 = "0.16.2"
⚡ Running sed -n '1,200p' chatgpt/Cargo.toml
└ [package]
edition = "2024"
name = "codex-chatgpt"
version = { workspace = true }
… +11 lines
serde_json = "1"
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
tempfile = "3"
thinking
Considering binary size impacts
I’m evaluating how binary size is influenced by various factors. There are many
binaries, such as codex and codex-exec, which include dependencies by default,
pulling in features like Tokio and hyper. For instance, reqwest includes
default features like TLS through rustls. If default features remain enabled,
it inflates the size with many additional components. I notice that for musl
targets, using vendored builds of OpenSSL could significantly increase binary
size. This is quite the intricate web of dependencies!
Analyzing musl and binary size
I’m considering the implications of building for musl with static linking.
In this case, OpenSSL might come into play through some crates, but reqwest
defaults to using rustls, although openssl-sys vendored could increase binary
size. It’s interesting to think about whether they’re targeting musl, especially
since it seems they might often be building for Mac and Linux with dynamic
linking instead.
I also realize there’s a difference between debug and release builds — release
should be smaller due to optimizations, but if they’re defaulting to debug
builds, that could lead to much larger binaries.
Assessing crate impacts on size
I need to be precise when analyzing binary sizes from various crates. Some
crates, like ratatui-image, include heavy features such as libjpeg decoders,
which can really bulk things up. Then there are the tree-sitter crates that
contain large static grammar tables, adding significant size. The starlark crate
and tracing-subscriber also contribute to size increases.
With static linking per binary being standard in Rust, it leads to multiple
copies of the same dependencies unless cdylibs are used. That makes the compile
times longer, but using LTO helps reduce bloat somewhat through deduplication.
Evaluating binary size factors
Having multiple binaries indeed increases the overall size significantly.
I'm considering options like upgrading to "opt-level = 'z'" or using
"strip=debuginfo" for reducing size. It's interesting that while the profile
only sets symbols for stripping, not debuginfo, this could inflate sizes if
retained in release builds.
The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps
debug information which may not align with the goal of minimizing size. I’d like
to verify with the Rust documentation, but my network access is restricted.
Explaining large binary sizes
I’m analyzing why binaries in this project are so large. The primary reasons
include monolithic static linking, where each binary includes the full
dependency graph without shared libraries. Heavy dependencies such as reqwest,
hyper, and TLS components also contribute significantly to size, along with
image decoders and other libraries.
Using MUSL targets means vendored OpenSSL can drastically increase the size due
to static linking. Furthermore, if the release profile keeps debug information
due to using "strip = symbols," that can lead to larger binaries as well. It’s a
mix of several factors, including panic strategies and platform-specific linking
behavior.
Identifying binary size contributors
I’m considering why binaries are still large even without static linking. The
tracing-subscriber crate potentially adds weight because it includes regex,
though it can sometimes use a lighter version. References from files like "core/
Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the
image crate.
To present this clearly, I’ll format the final answer with two main sections:
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question.
codex
Here’s what’s driving size in this workspace’s binaries.
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.
- 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 transitive set.
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
crossterm, and ratatui-image, which together contribute significant code size
(decoders, pixel ops, terminal backends).
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process, signal)
across many crates inflate code size even if only subsets are used 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.
Build-Mode Notes
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
contributor; if you build in release with that setting, binaries can still be
large.
- 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.
File diff suppressed because one or more lines are too long
-5
View File
@@ -1,5 +0,0 @@
// Aggregates all former standalone integration tests as modules.
mod status_indicator;
mod vt100_history;
mod vt100_live_commit;
mod vt100_streaming_no_dup;
@@ -1,24 +0,0 @@
//! Regression test: ensure that `StatusIndicatorWidget` sanitises ANSI escape
//! sequences so that no raw `\x1b` bytes are written into the backing
//! buffer. Rendering logic is tricky to unit‑test end‑to‑end, therefore we
//! verify the *public* contract of `ansi_escape_line()` which the widget now
//! relies on.
use codex_ansi_escape::ansi_escape_line;
#[test]
fn ansi_escape_line_strips_escape_sequences() {
let text_in_ansi_red = "\x1b[31mRED\x1b[0m";
// The returned line must contain three printable glyphs and **no** raw
// escape bytes.
let line = ansi_escape_line(text_in_ansi_red);
let combined: String = line
.spans
.iter()
.map(|span| span.content.to_string())
.collect();
assert_eq!(combined, "RED");
}
-245
View File
@@ -1,245 +0,0 @@
#![cfg(feature = "vt100-tests")]
#![expect(clippy::expect_used)]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
// Small helper macro to assert a collection contains an item with a clearer
// failure message.
macro_rules! assert_contains {
($collection:expr, $item:expr $(,)?) => {
assert!(
$collection.contains(&$item),
"Expected {:?} to contain {:?}",
$collection,
$item
);
};
($collection:expr, $item:expr, $($arg:tt)+) => {
assert!($collection.contains(&$item), $($arg)+);
};
}
struct TestScenario {
width: u16,
height: u16,
term: codex_tui::custom_terminal::Terminal<TestBackend>,
}
impl TestScenario {
fn new(width: u16, height: u16, viewport: Rect) -> Self {
let backend = TestBackend::new(width, height);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
term.set_viewport_area(viewport);
Self {
width,
height,
term,
}
}
fn run_insert(&mut self, lines: Vec<Line<'static>>) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines);
buf
}
fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec<String> {
let mut parser = vt100::Parser::new(self.height, self.width, 0);
parser.process(bytes);
let screen = parser.screen();
let mut rows: Vec<String> = Vec::with_capacity(self.height as usize);
for row in 0..self.height {
let mut s = String::with_capacity(self.width as usize);
for col in 0..self.width {
if let Some(cell) = screen.cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
s.push(ch);
} else {
s.push(' ');
}
} else {
s.push(' ');
}
}
rows.push(s.trim_end().to_string());
}
rows
}
}
#[test]
fn basic_insertion_no_wrap() {
// Screen of 20x6; viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec![Line::from("first"), Line::from("second")];
let buf = scenario.run_insert(lines);
let rows = scenario.screen_rows_from_bytes(&buf);
assert_contains!(rows, String::from("first"));
assert_contains!(rows, String::from("second"));
let first_idx = rows
.iter()
.position(|r| r == "first")
.expect("expected 'first' row to be present");
let second_idx = rows
.iter()
.position(|r| r == "second")
.expect("expected 'second' row to be present");
assert_eq!(second_idx, first_idx + 1, "rows should be adjacent");
}
#[test]
fn long_token_wraps() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let long = "A".repeat(45); // > 2 lines at width 20
let lines = vec![Line::from(long.clone())];
let buf = scenario.run_insert(lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
// Count total A's on the screen
let mut count_a = 0usize;
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col)
&& let Some(ch) = cell.contents().chars().next()
&& ch == 'A'
{
count_a += 1;
}
}
}
assert_eq!(
count_a,
long.len(),
"wrapped content did not preserve all characters"
);
}
#[test]
fn emoji_and_cjk() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let text = String::from("😀😀😀😀😀 你好世界");
let lines = vec![Line::from(text.clone())];
let buf = scenario.run_insert(lines);
let rows = scenario.screen_rows_from_bytes(&buf);
let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect();
for ch in text.chars().filter(|c| !c.is_whitespace()) {
assert!(
reconstructed.contains(ch),
"missing character {ch:?} in reconstructed screen"
);
}
}
#[test]
fn mixed_ansi_spans() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let line = Line::from(vec![
Span::styled("red", Style::default().fg(Color::Red)),
Span::raw("+plain"),
]);
let buf = scenario.run_insert(vec![line]);
let rows = scenario.screen_rows_from_bytes(&buf);
assert_contains!(rows, String::from("red+plain"));
}
#[test]
fn cursor_restoration() {
let area = Rect::new(0, 5, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec![Line::from("x")];
let buf = scenario.run_insert(lines);
let s = String::from_utf8_lossy(&buf);
// CUP to 1;1 (ANSI: ESC[1;1H)
assert!(
s.contains("\u{1b}[1;1H"),
"expected final CUP to 1;1 in output, got: {s:?}"
);
// Reset scroll region
assert!(
s.contains("\u{1b}[r"),
"expected reset scroll region in output, got: {s:?}"
);
}
#[test]
fn word_wrap_no_mid_word_split() {
// Screen of 40x10; viewport is the last row
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
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 buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("bo\nth"),
"word 'both' should not be split across lines:\n{joined}"
);
}
#[test]
fn em_dash_and_space_word_wrap() {
// Repro from report: ensure we break before "inside", not mid-word.
let area = Rect::new(0, 9, 40, 1);
let mut scenario = TestScenario::new(40, 10, area);
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
let buf = scenario.run_insert(vec![Line::from(sample)]);
let rows = scenario.screen_rows_from_bytes(&buf);
let joined = rows.join("\n");
assert!(
!joined.contains("insi\nde"),
"word 'inside' should not be split across lines:\n{joined}"
);
}
#[test]
fn pre_scroll_region_down() {
// Viewport not at bottom: y=3 (0-based), height=1
let area = Rect::new(0, 3, 20, 1);
let mut scenario = TestScenario::new(20, 6, area);
let lines = vec![Line::from("first"), Line::from("second")];
let buf = scenario.run_insert(lines);
let s = String::from_utf8_lossy(&buf);
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
assert!(
s.contains("\u{1b}[4;6r"),
"expected pre-scroll SetScrollRegion 4..6, got: {s:?}"
);
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
assert!(
s.contains("\u{1b}[4;1H"),
"expected cursor at top of pre-scroll region, got: {s:?}"
);
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
let ri_count = s.matches("\u{1b}M").count();
assert!(
ri_count >= 1,
"expected at least one RI (ESC M), got: {s:?}"
);
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
assert!(
s.contains("\u{1b}[1;5r"),
"expected insertion SetScrollRegion 1..5, got: {s:?}"
);
}
@@ -1,101 +0,0 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::text::Line;
#[test]
fn live_001_commit_on_overflow() {
let backend = TestBackend::new(20, 6);
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
let area = Rect::new(0, 5, 20, 1);
term.set_viewport_area(area);
// Build 5 explicit rows at width 20.
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
rb.push_fragment("one\n");
rb.push_fragment("two\n");
rb.push_fragment("three\n");
rb.push_fragment("four\n");
rb.push_fragment("five\n");
// Keep the last 3 in the live ring; commit the first 2.
let commit_rows = rb.drain_commit_ready(3);
let lines: Vec<Line<'static>> = commit_rows
.into_iter()
.map(|r| Line::from(r.text))
.collect();
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let mut parser = vt100::Parser::new(6, 20, 0);
parser.process(&buf);
let screen = parser.screen();
// The words "one" and "two" should appear above the viewport.
let mut joined = String::new();
for row in 0..6 {
for col in 0..20 {
if let Some(cell) = screen.cell(row, col) {
if let Some(ch) = cell.contents().chars().next() {
joined.push(ch);
} else {
joined.push(' ');
}
}
}
joined.push('\n');
}
assert!(
joined.contains("one"),
"expected committed 'one' to be visible\n{joined}"
);
assert!(
joined.contains("two"),
"expected committed 'two' to be visible\n{joined}"
);
// The last three (three,four,five) remain in the live ring, not committed here.
}
#[test]
fn live_002_pre_scroll_and_commit() {
let backend = TestBackend::new(20, 6);
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
Ok(t) => t,
Err(e) => panic!("failed to construct terminal: {e}"),
};
// Viewport not at bottom: y=3
let area = Rect::new(0, 3, 20, 1);
term.set_viewport_area(area);
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
rb.push_fragment("alpha\n");
rb.push_fragment("beta\n");
rb.push_fragment("gamma\n");
rb.push_fragment("delta\n");
// Keep 3, commit 1.
let commit_rows = rb.drain_commit_ready(3);
let lines: Vec<Line<'static>> = commit_rows
.into_iter()
.map(|r| Line::from(r.text))
.collect();
let mut buf: Vec<u8> = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
let s = String::from_utf8_lossy(&buf);
// Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region.
assert!(
s.contains("\u{1b}[4;6r"),
"expected pre-scroll region 4..6, got: {s:?}"
);
assert!(
s.contains("\u{1b}[4;1H"),
"expected cursor CUP 4;1H, got: {s:?}"
);
}
@@ -1,77 +0,0 @@
#![cfg(feature = "vt100-tests")]
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::text::Line;
fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal<TestBackend> {
let backend = TestBackend::new(20, 6);
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
.unwrap_or_else(|e| panic!("failed to construct terminal: {e}"));
term.set_viewport_area(viewport);
term
}
#[test]
fn stream_commit_trickle_no_duplication() {
// Viewport is the last row (height=1 at y=5)
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Step 1: commit first row
let mut out1 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out1,
vec![Line::from("one")],
);
// Step 2: later commit next row
let mut out2 = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut out2,
vec![Line::from("two")],
);
let combined = [out1, out2].concat();
let s = String::from_utf8_lossy(&combined);
assert_eq!(
s.matches("one").count(),
1,
"history line duplicated: {s:?}"
);
assert_eq!(
s.matches("two").count(),
1,
"history line duplicated: {s:?}"
);
assert!(
!s.contains("three"),
"live-only content leaked into history: {s:?}"
);
}
#[test]
fn live_ring_rows_not_inserted_into_history() {
let area = Rect::new(0, 5, 20, 1);
let mut t = term(area);
// Commit two rows to history.
let mut buf = Vec::new();
codex_tui::insert_history::insert_history_lines_to_writer(
&mut t,
&mut buf,
vec![Line::from("one"), Line::from("two")],
);
// The live ring might display tail+head rows like ["two", "three"],
// but only committed rows should be present in the history ANSI stream.
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("one"));
assert!(s.contains("two"));
assert!(
!s.contains("three"),
"uncommitted live-ring content should not be inserted into history: {s:?}"
);
}