From 299b8ac4455a76d510f9967f0392058c62e4a7ce Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Tue, 3 Mar 2026 15:31:52 -0800 Subject: [PATCH 01/23] tui: align pending steers with core acceptance (#12868) ## Summary - submit `Enter` steers immediately while a turn is already running instead of routing them through `queued_user_messages` - keep those submitted steers visible in the footer as `pending_steers` until core records them as a user message or aborts the turn - reconcile pending steers on `ItemCompleted(UserMessage)`, not `RawResponseItem` - emit user-message item lifecycle for leftover pending input at task finish, then remove the TUI `TurnComplete` fallback - keep `queued_user_messages` for actual queued drafts, rendered below pending steers ## Problem While the assistant was generating, pressing `Enter` could send the input into `queued_user_messages`. That queue only drains after the turn ends, so ordinary steers behaved like queued drafts instead of landing at the next core sampling boundary. The first version of this fix also used `RawResponseItem` to decide when a steer had landed. Review feedback was that this is the wrong abstraction for client behavior. There was also a late edge case in core: if pending steer input was accepted after the final sampling decision but before `TurnComplete`, core would record that user message into history at task finish without emitting `ItemStarted(UserMessage)` / `ItemCompleted(UserMessage)`. TUI had a fallback to paper over that gap locally. ## Approach - `Enter` during an active turn now submits a normal `Op::UserTurn` immediately - TUI keeps a local pending-steer preview instead of rendering that user message into history immediately - when core records the steer as `ItemCompleted(UserMessage)`, TUI matches and removes the corresponding pending preview, then renders the committed user message - core now emits the same user-message lifecycle when `on_task_finished(...)` drains leftover pending user input, before `TurnComplete` - with that lifecycle gap closed in core, TUI no longer needs to flush pending steers into history on `TurnComplete` - if the turn is interrupted, pending steers and queued drafts are both restored into the composer, with pending steers first ## Notes - `Tab` still uses the real queued-message path - `queued_user_messages` and `pending_steers` are separate state with separate semantics - the pending-steer matching key is built directly from `UserInput` - this removes the new TUI dependency on `RawResponseItem` ## Validation - `just fmt` - `cargo test -p codex-core task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input -- --nocapture` - `cargo test -p codex-tui` --- codex-rs/core/src/codex.rs | 72 +- codex-rs/core/src/tasks/mod.rs | 23 +- codex-rs/tui/src/bottom_pane/mod.rs | 38 +- ...r_messages.rs => pending_input_preview.rs} | 144 ++- ...iew__tests__render_many_line_message.snap} | 0 ...sts__render_more_than_three_messages.snap} | 0 ...t_preview__tests__render_one_message.snap} | 0 ...view__tests__render_one_pending_steer.snap | 15 + ..._pending_steers_above_queued_messages.snap | 25 + ..._preview__tests__render_two_messages.snap} | 0 ...eview__tests__render_wrapped_message.snap} | 0 codex-rs/tui/src/chatwidget.rs | 245 +++-- codex-rs/tui/src/chatwidget/realtime.rs | 86 +- ...e_final_message_are_rendered_snapshot.snap | 5 - codex-rs/tui/src/chatwidget/tests.rs | 858 +++++++++++++++--- 15 files changed, 1247 insertions(+), 264 deletions(-) rename codex-rs/tui/src/bottom_pane/{queued_user_messages.rs => pending_input_preview.rs} (55%) rename codex-rs/tui/src/bottom_pane/snapshots/{codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap => codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap} (100%) rename codex-rs/tui/src/bottom_pane/snapshots/{codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap => codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap} (100%) rename codex-rs/tui/src/bottom_pane/snapshots/{codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap => codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap} (100%) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap rename codex-rs/tui/src/bottom_pane/snapshots/{codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap => codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap} (100%) rename codex-rs/tui/src/bottom_pane/snapshots/{codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap => codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap} (100%) delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3a28a3e83b..7736aa1280 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6590,6 +6590,7 @@ mod tests { use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; + use crate::protocol::TurnCompleteEvent; use crate::protocol::UserMessageEvent; use crate::rollout::policy::EventPersistenceMode; use crate::rollout::recorder::RolloutRecorder; @@ -9307,8 +9308,8 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn task_finish_persists_leftover_pending_input() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; + async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; let input = vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -9323,6 +9324,8 @@ mod tests { ) .await; + while rx.try_recv().is_ok() {} + sess.inject_response_items(vec![ResponseInputItem::Message { role: "user".to_string(), content: vec![ContentItem::InputText { @@ -9348,6 +9351,71 @@ mod tests { history.raw_items().iter().any(|item| item == &expected), "expected pending input to be persisted into history on turn completion" ); + + let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected raw response item event") + .expect("channel open"); + assert!(matches!(first.msg, EventMsg::RawResponseItem(_))); + + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected item started event") + .expect("channel open"); + assert!(matches!( + second.msg, + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::UserMessage(UserMessageItem { content, .. }), + .. + }) if content == vec![UserInput::Text { + text: "late pending input".to_string(), + text_elements: Vec::new(), + }] + )); + + let third = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected item completed event") + .expect("channel open"); + assert!(matches!( + third.msg, + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(UserMessageItem { content, .. }), + .. + }) if content == vec![UserInput::Text { + text: "late pending input".to_string(), + text_elements: Vec::new(), + }] + )); + + let fourth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected legacy user message event") + .expect("channel open"); + assert!(matches!( + fourth.msg, + EventMsg::UserMessage(UserMessageEvent { + message, + images, + text_elements, + local_images, + }) if message == "late pending input" + && images == Some(Vec::new()) + && text_elements.is_empty() + && local_images.is_empty() + )); + + let fifth = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected turn complete event") + .expect("channel open"); + assert!(matches!( + fifth.msg, + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id, + last_agent_message: None, + }) if turn_id == tc.sub_id + )); } #[tokio::test] diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 97719f1049..5133dc1e27 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -22,6 +22,7 @@ use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG; +use crate::event_mapping::parse_turn_item; use crate::models_manager::manager::ModelsManager; use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; @@ -30,6 +31,7 @@ use crate::protocol::TurnCompleteEvent; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -213,8 +215,25 @@ impl Session { .into_iter() .map(ResponseItem::from) .collect::>(); - self.record_conversation_items(turn_context.as_ref(), &pending_response_items) - .await; + for response_item in pending_response_items { + if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) { + // Keep leftover user input on the same persistence + lifecycle path as the + // normal pre-sampling drain. This helper records the response item once, then + // emits ItemStarted/UserMessage and ItemCompleted/UserMessage for clients. + self.record_user_prompt_and_emit_turn_item( + turn_context.as_ref(), + &user_message.content, + response_item, + ) + .await; + } else { + self.record_conversation_items( + turn_context.as_ref(), + std::slice::from_ref(&response_item), + ) + .await; + } + } } let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 02c7faeebb..74862df999 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,8 +17,8 @@ use std::path::PathBuf; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::pending_input_preview::PendingInputPreview; use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; -use crate::bottom_pane::queued_user_messages::QueuedUserMessages; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -93,9 +93,9 @@ pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; pub(crate) use status_line_setup::StatusLineSetupView; mod paste_burst; +mod pending_input_preview; mod pending_thread_approvals; pub mod popup_consts; -mod queued_user_messages; mod scroll_state; mod selection_popup_common; mod textarea; @@ -172,8 +172,8 @@ pub(crate) struct BottomPane { /// When a status row exists, this summary is mirrored inline in that row; /// when no status row exists, it renders as its own footer row. unified_exec_footer: UnifiedExecFooter, - /// Queued user messages to show above the composer while a turn is running. - queued_user_messages: QueuedUserMessages, + /// Preview of pending steers and queued drafts shown above the composer. + pending_input_preview: PendingInputPreview, /// Inactive threads with pending approval requests. pending_thread_approvals: PendingThreadApprovals, context_window_percent: Option, @@ -223,7 +223,7 @@ impl BottomPane { is_task_running: false, status: None, unified_exec_footer: UnifiedExecFooter::new(), - queued_user_messages: QueuedUserMessages::new(), + pending_input_preview: PendingInputPreview::new(), pending_thread_approvals: PendingThreadApprovals::new(), esc_backtrack_hint: false, animations_enabled, @@ -317,7 +317,7 @@ impl BottomPane { /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { - self.queued_user_messages.set_edit_binding(binding); + self.pending_input_preview.set_edit_binding(binding); self.request_redraw(); } @@ -774,9 +774,14 @@ impl BottomPane { true } - /// Update the queued messages preview shown above the composer. - pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { - self.queued_user_messages.messages = queued; + /// Update the pending-input preview shown above the composer. + pub(crate) fn set_pending_input_preview( + &mut self, + queued: Vec, + pending_steers: Vec, + ) { + self.pending_input_preview.pending_steers = pending_steers; + self.pending_input_preview.queued_messages = queued; self.request_redraw(); } @@ -1019,18 +1024,19 @@ impl BottomPane { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); - let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() + || !self.pending_input_preview.pending_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); - let has_inline_previews = has_pending_thread_approvals || has_queued_messages; + let has_inline_previews = has_pending_thread_approvals || has_pending_input; if has_inline_previews && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } flex.push(1, RenderableItem::Borrowed(&self.pending_thread_approvals)); - if has_pending_thread_approvals && has_queued_messages { + if has_pending_thread_approvals && has_pending_input { flex.push(0, RenderableItem::Owned("".into())); } - flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + flex.push(1, RenderableItem::Borrowed(&self.pending_input_preview)); if !has_inline_previews && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } @@ -1406,7 +1412,7 @@ mod tests { StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); let width = 48; let height = pane.desired_height(width); @@ -1433,7 +1439,7 @@ mod tests { }); pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); pane.hide_status_indicator(); let width = 48; @@ -1461,7 +1467,7 @@ mod tests { }); pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + pane.set_pending_input_preview(vec!["Queued follow-up question".to_string()], Vec::new()); let width = 48; let height = pane.desired_height(width); diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs similarity index 55% rename from codex-rs/tui/src/bottom_pane/queued_user_messages.rs rename to codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 30a525f457..da7bc8cb69 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,23 +10,26 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays a list of user messages queued while a turn is in progress. +/// Widget that displays pending steers plus user messages queued while a turn is in progress. /// -/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the -/// user how to pop the most recent queued message back into the composer. -/// Because some terminals intercept certain modifier-key combinations, the -/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding). -pub(crate) struct QueuedUserMessages { - pub messages: Vec, +/// The widget shows pending steers first, then queued user messages. It only +/// shows the edit hint at the bottom (e.g. "⌥ + ↑ edit") when there are actual +/// queued user messages to pop back into the composer. Because some terminals +/// intercept certain modifier-key combinations, the displayed binding is +/// configurable via [`set_edit_binding`](Self::set_edit_binding). +pub(crate) struct PendingInputPreview { + pub pending_steers: Vec, + pub queued_messages: Vec, /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. edit_binding: key_hint::KeyBinding, } -impl QueuedUserMessages { +impl PendingInputPreview { pub(crate) fn new() -> Self { Self { - messages: Vec::new(), + pending_steers: Vec::new(), + queued_messages: Vec::new(), edit_binding: key_hint::alt(KeyCode::Up), } } @@ -39,13 +42,31 @@ impl QueuedUserMessages { } fn as_renderable(&self, width: u16) -> Box { - if self.messages.is_empty() || width < 4 { + if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 { return Box::new(()); } let mut lines = vec![]; - for message in &self.messages { + for steer in &self.pending_steers { + let wrapped = adaptive_wrap_lines( + steer + .lines() + .map(|line| format!("pending steer: {line}").dim()), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ! ".dim())) + .subsequent_indent(Line::from(" ")), + ); + let len = wrapped.len(); + for line in wrapped.into_iter().take(3) { + lines.push(line); + } + if len > 3 { + lines.push(Line::from(" …".dim())); + } + } + + for message in &self.queued_messages { let wrapped = adaptive_wrap_lines( message.lines().map(|line| line.dim().italic()), RtOptions::new(width as usize) @@ -61,20 +82,22 @@ impl QueuedUserMessages { } } - lines.push( - Line::from(vec![ - " ".into(), - self.edit_binding.into(), - " edit".into(), - ]) - .dim(), - ); + if !self.queued_messages.is_empty() { + lines.push( + Line::from(vec![ + " ".into(), + self.edit_binding.into(), + " edit".into(), + ]) + .dim(), + ); + } Paragraph::new(lines).into() } } -impl Renderable for QueuedUserMessages { +impl Renderable for PendingInputPreview { fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; @@ -96,21 +119,21 @@ mod tests { #[test] fn desired_height_empty() { - let queue = QueuedUserMessages::new(); + let queue = PendingInputPreview::new(); assert_eq!(queue.desired_height(40), 0); } #[test] fn desired_height_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); assert_eq!(queue.desired_height(40), 2); } #[test] fn render_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -120,9 +143,11 @@ mod tests { #[test] fn render_two_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -132,11 +157,17 @@ mod tests { #[test] fn render_more_than_three_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); - queue.messages.push("This is a third message".to_string()); - queue.messages.push("This is a fourth message".to_string()); + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push("Hello, world!".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); + queue + .queued_messages + .push("This is a third message".to_string()); + queue + .queued_messages + .push("This is a fourth message".to_string()); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -146,11 +177,13 @@ mod tests { #[test] fn render_wrapped_message() { - let mut queue = QueuedUserMessages::new(); + let mut queue = PendingInputPreview::new(); queue - .messages + .queued_messages .push("This is a longer message that should be wrapped".to_string()); - queue.messages.push("This is another message".to_string()); + queue + .queued_messages + .push("This is another message".to_string()); let width = 40; let height = queue.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -160,9 +193,9 @@ mod tests { #[test] fn render_many_line_message() { - let mut queue = QueuedUserMessages::new(); + let mut queue = PendingInputPreview::new(); queue - .messages + .queued_messages .push("This is\na message\nwith many\nlines".to_string()); let width = 40; let height = queue.desired_height(width); @@ -173,8 +206,8 @@ mod tests { #[test] fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push( + let mut queue = PendingInputPreview::new(); + queue.queued_messages.push( "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789" .to_string(), ); @@ -202,4 +235,35 @@ mod tests { "expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}" ); } + + #[test] + fn render_one_pending_steer() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_one_pending_steer", format!("{buf:?}")); + } + + #[test] + fn render_pending_steers_above_queued_messages() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue + .pending_steers + .push("Check the last command output.".to_string()); + queue + .queued_messages + .push("Queued follow-up question".to_string()); + let width = 52; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_pending_steers_above_queued_messages", + format!("{buf:?}") + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap similarity index 100% rename from codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap similarity index 100% rename from codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap similarity index 100% rename from codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap new file mode 100644 index 0000000000..d738b7dc09 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 237 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 1 }, + content: [ + " ! pending steer: Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap new file mode 100644 index 0000000000..6979d507df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 252 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 52, height: 4 }, + content: [ + " ! pending steer: Please continue. ", + " ! pending steer: Check the last command output. ", + " ↳ Queued follow-up question ", + " ⌥ + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 35, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap similarity index 100% rename from codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap similarity index 100% rename from codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 51f39c1f2a..a0d3454183 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -37,6 +37,7 @@ use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; +use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] use crate::audio_device::list_realtime_audio_device_names; @@ -85,6 +86,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::local_image_label_text; @@ -618,6 +620,11 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Steers already submitted to core but not yet committed into history. + // + // The bottom pane shows these above queued drafts until core records the + // corresponding user message item. + pending_steers: VecDeque, /// Terminal-appropriate keybinding for popping the most-recently queued /// message back into the composer. Determined once at construction time via /// [`queued_message_edit_binding_for_terminal`] and propagated to @@ -650,7 +657,9 @@ pub(crate) struct ChatWidget { had_work_activity: bool, // Whether the current turn emitted a plan update. saw_plan_update_this_turn: bool, - // Whether the current turn emitted a proposed plan item. + // Whether the current turn emitted a proposed plan item that has not been superseded by a + // later steer. This is cleared when the user submits a steer so the plan popup only appears + // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, // Incremental buffer for streamed plan content. plan_delta_buffer: String, @@ -751,6 +760,11 @@ impl From<&str> for UserMessage { } } +struct PendingSteer { + user_message: UserMessage, + compare_key: PendingSteerCompareKey, +} + pub(crate) fn create_initial_user_message( text: Option, local_image_paths: Vec, @@ -778,6 +792,21 @@ pub(crate) fn create_initial_user_message( } } +fn append_text_with_rebased_elements( + target_text: &mut String, + target_text_elements: &mut Vec, + text: &str, + text_elements: impl IntoIterator, +) { + let offset = target_text.len(); + target_text.push_str(text); + target_text_elements.extend(text_elements.into_iter().map(|mut element| { + element.byte_range.start += offset; + element.byte_range.end += offset; + element + })); +} + // When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering // its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so // the combined local_image_paths order matches the labels, even if placeholders were moved @@ -1290,17 +1319,24 @@ impl ChatWidget { self.request_redraw(); } - fn on_agent_message(&mut self, message: String) { - // If we have a stream_controller, then the final agent message is redundant and will be a - // duplicate of what has already been streamed. - if self.stream_controller.is_none() && !message.is_empty() { - self.handle_streaming_delta(message); + fn finalize_completed_assistant_message(&mut self, message: Option<&str>) { + // If we have a stream_controller, the finalized message payload is redundant because the + // visible content has already been accumulated through deltas. + if self.stream_controller.is_none() + && let Some(message) = message + && !message.is_empty() + { + self.handle_streaming_delta(message.to_string()); } self.flush_answer_stream_with_separator(); self.handle_stream_finished(); self.request_redraw(); } + fn on_agent_message(&mut self, message: String) { + self.finalize_completed_assistant_message(Some(&message)); + } + fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } @@ -1483,7 +1519,10 @@ impl ChatWidget { self.unified_exec_wait_streak = None; self.request_redraw(); - if !from_replay && self.queued_user_messages.is_empty() { + let had_pending_steers = !self.pending_steers.is_empty(); + self.refresh_pending_input_preview(); + + if !from_replay && self.queued_user_messages.is_empty() && !had_pending_steers { self.maybe_prompt_plan_implementation(); } // Keep this flag for replayed completion events so a subsequent live TurnComplete can @@ -1865,30 +1904,31 @@ impl ChatWidget { if reason == TurnAbortReason::Interrupted { self.clear_unified_exec_processes(); } - if reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), )); } - if let Some(combined) = self.drain_queued_messages_for_restore() { + // Core clears pending_input before emitting TurnAborted, so any unacknowledged steers + // still tracked here must be restored locally instead of waiting for a later commit. + if let Some(combined) = self.drain_pending_messages_for_restore() { self.restore_user_message_to_composer(combined); - self.refresh_queued_user_messages(); } + self.refresh_pending_input_preview(); self.request_redraw(); } - /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// Merge pending steers, queued drafts, and the current composer state into a single message. /// - /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we - /// must renumber placeholders in a stable order so the merged attachment list stays aligned - /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and - /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing - /// to restore. - fn drain_queued_messages_for_restore(&mut self) -> Option { - if self.queued_user_messages.is_empty() { + /// Each pending message numbers attachments from `[Image #1]` relative to its own remote + /// images. When we concatenate multiple messages after interrupt, we must renumber local-image + /// placeholders in a stable order and rebase text element byte ranges so the restored composer + /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to + /// restore. + fn drain_pending_messages_for_restore(&mut self) -> Option { + if self.pending_steers.is_empty() && self.queued_user_messages.is_empty() { return None; } @@ -1900,7 +1940,12 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + let mut to_merge: Vec = self + .pending_steers + .drain(..) + .map(|steer| steer.user_message) + .collect(); + to_merge.extend(self.queued_user_messages.drain(..)); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() || !existing_message.remote_image_urls.is_empty() @@ -1915,7 +1960,6 @@ impl ChatWidget { remote_image_urls: Vec::new(), mention_bindings: Vec::new(), }; - let mut combined_offset = 0usize; let total_remote_images = to_merge .iter() .map(|message| message.remote_image_urls.len()) @@ -1925,22 +1969,23 @@ impl ChatWidget { for (idx, message) in to_merge.into_iter().enumerate() { if idx > 0 { combined.text.push('\n'); - combined_offset += 1; } - let message = remap_placeholders_for_message(message, &mut next_image_label); - let base = combined_offset; - combined.text.push_str(&message.text); - combined_offset += message.text.len(); - combined - .text_elements - .extend(message.text_elements.into_iter().map(|mut elem| { - elem.byte_range.start += base; - elem.byte_range.end += base; - elem - })); - combined.local_images.extend(message.local_images); - combined.remote_image_urls.extend(message.remote_image_urls); - combined.mention_bindings.extend(message.mention_bindings); + let UserMessage { + text, + text_elements, + local_images, + remote_image_urls, + mention_bindings, + } = remap_placeholders_for_message(message, &mut next_image_label); + append_text_with_rebased_elements( + &mut combined.text, + &mut combined.text_elements, + &text, + text_elements, + ); + combined.local_images.extend(local_images); + combined.remote_image_urls.extend(remote_image_urls); + combined.mention_bindings.extend(mention_bindings); } Some(combined) @@ -2350,6 +2395,15 @@ impl ChatWidget { /// returns once stream queues are idle. Final-answer completion (or absent /// phase for legacy models) clears the flag to preserve historical behavior. fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) { + let mut message = String::new(); + for content in &item.content { + match content { + AgentMessageContent::Text { text } => message.push_str(text), + } + } + self.finalize_completed_assistant_message( + (!message.is_empty()).then_some(message.as_str()), + ); self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. Some(MessagePhase::FinalAnswer) | None => false, @@ -2915,6 +2969,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3099,6 +3154,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), queued_message_edit_binding, show_welcome_banner: is_first_run, startup_tooltip_override, @@ -3264,6 +3320,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), queued_message_edit_binding, show_welcome_banner: false, startup_tooltip_override: None, @@ -3394,7 +3451,7 @@ impl ChatWidget { { if let Some(user_message) = self.queued_user_messages.pop_back() { self.restore_user_message_to_composer(user_message); - self.refresh_queued_user_messages(); + self.refresh_pending_input_preview(); self.request_redraw(); } return; @@ -3429,18 +3486,19 @@ impl ChatWidget { .bottom_pane .take_recent_submission_mention_bindings(), }; + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return; + } let Some(user_message) = self.maybe_defer_user_message_for_realtime(user_message) else { return; }; - // Submissions during active final-answer streaming can race with turn - // completion and strand the UI in a running state. Queue those inputs instead - // of injecting immediately; `on_task_complete()` drains this FIFO via - // `maybe_send_next_queued_input()`, so no typed prompt is dropped. - let should_submit_now = self.is_session_configured() - && !self.is_plan_streaming_in_tui() - && self.stream_controller.is_none(); + let should_submit_now = + self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { // Submitted is emitted when user submits. // Reset any reasoning header only when we are actually submitting a turn. @@ -4086,7 +4144,7 @@ impl ChatWidget { || self.is_review_mode { self.queued_user_messages.push_back(user_message); - self.refresh_queued_user_messages(); + self.refresh_pending_input_preview(); } else { self.submit_user_message(user_message); } @@ -4096,7 +4154,12 @@ impl ChatWidget { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); - self.refresh_queued_user_messages(); + self.refresh_pending_input_preview(); + return; + } + if self.is_review_mode { + self.queued_user_messages.push_back(user_message); + self.refresh_pending_input_preview(); return; } @@ -4123,6 +4186,7 @@ impl ChatWidget { return; } + let render_in_history = !self.agent_turn_running; let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. @@ -4251,6 +4315,16 @@ impl ChatWidget { } else { None }; + let pending_steer = (!render_in_history).then(|| PendingSteer { + user_message: UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }, + compare_key: Self::pending_steer_compare_key_from_items(&items), + }); let personality = self .config .personality @@ -4271,9 +4345,9 @@ impl ChatWidget { personality, }; - self.codex_op_tx.send(op).unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); + if !self.submit_op(op) { + return; + } // Persist the text to cross-session message history. if !text.is_empty() { @@ -4292,8 +4366,14 @@ impl ChatWidget { }); } + if let Some(pending_steer) = pending_steer { + self.pending_steers.push_back(pending_steer); + self.saw_plan_item_this_turn = false; + self.refresh_pending_input_preview(); + } + // Show replayable user content in conversation history. - if !text.is_empty() { + if render_in_history && !text.is_empty() { let local_image_paths = local_images .into_iter() .map(|img| img.path) @@ -4311,7 +4391,7 @@ impl ChatWidget { local_image_paths, remote_image_urls, )); - } else if !remote_image_urls.is_empty() { + } else if render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = Some(Self::rendered_user_message_event_from_parts( String::new(), @@ -4424,9 +4504,18 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { + EventMsg::AgentMessage(AgentMessageEvent { .. }) + if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) + && !self.is_review_mode => {} + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) + if from_replay || self.is_review_mode => + { + // TODO(ccunningham): stop relying on legacy AgentMessage in review mode, + // including thread-snapshot replay, and forward + // ItemCompleted(TurnItem::AgentMessage(_)) instead. self.on_agent_message(message) } + EventMsg::AgentMessage(AgentMessageEvent { .. }) => {} EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } @@ -4481,6 +4570,8 @@ impl ChatWidget { self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { + self.pending_steers.clear(); + self.refresh_pending_input_preview(); self.on_error("Turn aborted: replaced by a new task".to_owned()) } TurnAbortReason::ReviewEnded => { @@ -4599,6 +4690,42 @@ impl ChatWidget { } EventMsg::ItemCompleted(event) => { let item = event.item; + if !from_replay && let codex_protocol::items::TurnItem::UserMessage(item) = &item { + let EventMsg::UserMessage(event) = item.as_legacy_event() else { + unreachable!("user message item should convert to a legacy user message"); + }; + let rendered = Self::rendered_user_message_event_from_event(&event); + let compare_key = Self::pending_steer_compare_key_from_item(item); + if self + .pending_steers + .front() + .is_some_and(|pending| pending.compare_key == compare_key) + { + if let Some(pending) = self.pending_steers.pop_front() { + self.refresh_pending_input_preview(); + let pending_event = UserMessageEvent { + message: pending.user_message.text, + images: Some(pending.user_message.remote_image_urls), + local_images: pending + .user_message + .local_images + .into_iter() + .map(|image| image.path) + .collect(), + text_elements: pending.user_message.text_elements, + }; + self.on_user_message_event(pending_event); + } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) + { + tracing::warn!( + "pending steer matched compare key but queue was empty when rendering committed user message" + ); + self.on_user_message_event(event); + } + } else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) { + self.on_user_message_event(event); + } + } if let codex_protocol::items::TurnItem::Plan(plan_item) = &item { self.on_plan_item_completed(plan_item.text.clone()); } @@ -4749,17 +4876,23 @@ impl ChatWidget { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). - self.refresh_queued_user_messages(); + self.refresh_pending_input_preview(); } - /// Rebuild and update the queued user messages from the current queue. - fn refresh_queued_user_messages(&mut self) { - let messages: Vec = self + /// Rebuild and update the bottom-pane pending-input preview. + fn refresh_pending_input_preview(&mut self) { + let queued_messages: Vec = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect(); - self.bottom_pane.set_queued_user_messages(messages); + let pending_steers: Vec = self + .pending_steers + .iter() + .map(|steer| steer.user_message.text.clone()) + .collect(); + self.bottom_pane + .set_pending_input_preview(queued_messages, pending_steers); } pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 2970ac4d3a..32f0a15287 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -49,10 +49,16 @@ impl RealtimeConversationUiState { #[derive(Clone, Debug, PartialEq)] pub(super) struct RenderedUserMessageEvent { - message: String, - remote_image_urls: Vec, - local_images: Vec, - text_elements: Vec, + pub(super) message: String, + pub(super) remote_image_urls: Vec, + pub(super) local_images: Vec, + pub(super) text_elements: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PendingSteerCompareKey { + pub(super) message: String, + pub(super) image_count: usize, } impl ChatWidget { @@ -81,6 +87,78 @@ impl ChatWidget { ) } + /// Build the compare key for a submitted pending steer without invoking the + /// expensive request-serialization path. Pending steers only need to match the + /// committed `ItemCompleted(UserMessage)` emitted after core drains input, which + /// preserves flattened text and total image count but not UI-only text ranges or + /// local image paths. + pub(super) fn pending_steer_compare_key_from_items( + items: &[UserInput], + ) -> PendingSteerCompareKey { + let mut message = String::new(); + let mut image_count = 0; + + for item in items { + match item { + UserInput::Text { text, .. } => message.push_str(text), + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} + } + } + + PendingSteerCompareKey { + message, + image_count, + } + } + + pub(super) fn pending_steer_compare_key_from_item( + item: &codex_protocol::items::UserMessageItem, + ) -> PendingSteerCompareKey { + Self::pending_steer_compare_key_from_items(&item.content) + } + + #[cfg(test)] + pub(super) fn rendered_user_message_event_from_inputs( + items: &[UserInput], + ) -> RenderedUserMessageEvent { + let mut message = String::new(); + let mut remote_image_urls = Vec::new(); + let mut local_images = Vec::new(); + let mut text_elements = Vec::new(); + + for item in items { + match item { + UserInput::Text { + text, + text_elements: current_text_elements, + } => append_text_with_rebased_elements( + &mut message, + &mut text_elements, + text, + current_text_elements.iter().map(|element| { + TextElement::new( + element.byte_range, + element.placeholder(text).map(str::to_string), + ) + }), + ), + UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()), + UserInput::LocalImage { path } => local_images.push(path.clone()), + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} + } + } + + Self::rendered_user_message_event_from_parts( + message, + text_elements, + local_images, + remote_image_urls, + ) + } + pub(super) fn should_render_realtime_user_message_event( &self, event: &UserMessageEvent, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap deleted file mode 100644 index 6062087181..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a720559b5c..6ea109dacb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -44,6 +44,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; @@ -211,6 +212,45 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + chat.handle_codex_event_replay(Event { + id: "turn-1".into(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + item: TurnItem::AgentMessage(AgentMessageItem { + id: "msg-1".to_string(), + content: vec![AgentMessageContent::Text { + text: "assistant reply".to_string(), + }], + phase: None, + }), + }), + }); + chat.handle_codex_event_replay(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "assistant reply".to_string(), + phase: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected replayed assistant message to render once" + ); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("assistant reply"), + "expected replayed assistant message, got {rendered:?}" + ); +} + #[tokio::test] async fn replayed_user_message_preserves_text_elements_and_local_images() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1181,7 +1221,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { text_elements: second_elements, mention_bindings: Vec::new(), }); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); chat.bottom_pane .set_composer_text(existing_text, existing_elements, existing_images.clone()); @@ -1251,7 +1291,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { text_elements: Vec::new(), mention_bindings: Vec::new(), }); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { id: "interrupt".into(), @@ -1451,6 +1491,58 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } +#[tokio::test] +async fn live_agent_message_renders_during_review_mode() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.handle_codex_event(Event { + id: "review-message".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Review progress update".to_string(), + phase: None, + }), + }); + + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("Review progress update")); +} + +#[tokio::test] +async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + chat.handle_codex_event_replay(Event { + id: "review-start".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.handle_codex_event_replay(Event { + id: "review-message".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Review progress update".to_string(), + phase: None, + }), + }); + + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("Review progress update")); +} + /// Exiting review restores the pre-review context window indicator. #[tokio::test] async fn review_restores_context_window_indicator() { @@ -1720,6 +1812,7 @@ async fn make_chatwidget_manual( show_welcome_banner: true, startup_tooltip_override: None, queued_user_messages: VecDeque::new(), + pending_steers: VecDeque::new(), queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, @@ -2753,6 +2846,94 @@ async fn plan_implementation_popup_shows_after_proposed_plan_output() { ); } +#[tokio::test] +async fn plan_implementation_popup_skips_when_steer_follows_proposed_plan() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + chat.thread_id = Some(ThreadId::new()); + + chat.on_task_started(); + chat.on_plan_item_completed( + "- Step 1 +- Step 2 +" + .to_string(), + ); + chat.bottom_pane + .set_composer_text("Please continue.".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Please continue.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + complete_user_message(&mut chat, "user-1", "Please continue."); + chat.on_task_complete(None, false); + + let popup = render_bottom_popup(&chat, 80); + assert!( + !popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected no plan popup after a steer follows the plan, got {popup:?}" + ); +} + +#[tokio::test] +async fn plan_implementation_popup_shows_after_new_plan_follows_steer() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + chat.thread_id = Some(ThreadId::new()); + + chat.on_task_started(); + chat.on_plan_item_completed( + "- Initial plan +" + .to_string(), + ); + chat.bottom_pane + .set_composer_text("Please revise.".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Please revise.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + complete_user_message(&mut chat, "user-1", "Please revise."); + chat.on_plan_item_completed( + "- Revised plan +" + .to_string(), + ); + chat.on_task_complete(None, false); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains(PLAN_IMPLEMENTATION_TITLE), + "expected plan popup after a newer plan follows the steer, got {popup:?}" + ); +} + #[tokio::test] async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -3064,6 +3245,41 @@ fn complete_assistant_message( }); } +fn pending_steer(text: &str) -> PendingSteer { + PendingSteer { + user_message: UserMessage::from(text), + compare_key: PendingSteerCompareKey { + message: text.to_string(), + image_count: 0, + }, + } +} + +fn complete_user_message(chat: &mut ChatWidget, item_id: &str, text: &str) { + complete_user_message_for_inputs( + chat, + item_id, + vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ); +} + +fn complete_user_message_for_inputs(chat: &mut ChatWidget, item_id: &str, content: Vec) { + chat.handle_codex_event(Event { + id: format!("raw-{item_id}"), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + item: TurnItem::UserMessage(UserMessageItem { + id: item_id.to_string(), + content, + }), + }), + }); +} + fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) } @@ -3166,7 +3382,7 @@ async fn alt_up_edits_most_recent_queued_message() { .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); // Press Alt+Up to edit the most recent (last) queued message. chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); @@ -3200,7 +3416,7 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); // Press Shift+Left to edit the most recent (last) queued message. chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); @@ -3459,8 +3675,8 @@ async fn unified_exec_begin_restores_working_status_snapshot() { } #[tokio::test] -async fn enter_queues_while_plan_stream_is_active() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn steer_enter_queues_while_plan_stream_is_active() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); let plan_mask = @@ -3469,6 +3685,7 @@ async fn enter_queues_while_plan_stream_is_active() { chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.on_plan_delta("- Step 1".to_string()); + let _ = drain_insert_history(&mut rx); chat.bottom_pane .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); @@ -3480,12 +3697,44 @@ async fn enter_queues_while_plan_stream_is_active() { chat.queued_user_messages.front().unwrap().text, "queued submission" ); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!(chat.pending_steers.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test] -async fn steer_enter_queues_while_final_answer_stream_is_active() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + + chat.bottom_pane + .set_composer_text("queued while running".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "queued while running" + ); + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(drain_insert_history(&mut rx).is_empty()); + + complete_user_message(&mut chat, "user-1", "queued while running"); + + assert!(chat.pending_steers.is_empty()); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("queued while running")); +} + +#[tokio::test] +async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); // Keep the assistant stream open (no commit tick/finalize) to model the repro window: @@ -3499,26 +3748,228 @@ async fn steer_enter_queues_while_final_answer_stream_is_active() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.pending_steers.front().unwrap().user_message.text, "queued while streaming" ); - assert_no_submit_op(&mut op_rx); - - // Once final output ends, the queued input must be submitted automatically. - chat.on_task_complete(None, false); - - assert!(chat.queued_user_messages.is_empty()); match next_submit_op(&mut op_rx) { Op::UserTurn { .. } => {} - other => panic!("expected Op::UserTurn after stream completion, got {other:?}"), + other => panic!("expected Op::UserTurn, got {other:?}"), } + assert!(drain_insert_history(&mut rx).is_empty()); + + complete_user_message(&mut chat, "user-1", "queued while streaming"); + + assert!(chat.pending_steers.is_empty()); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("queued while streaming")); +} + +#[tokio::test] +async fn failed_pending_steer_submit_does_not_add_pending_preview() { + let (mut chat, mut rx, op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + drop(op_rx); + + chat.bottom_pane.set_composer_text( + "queued while streaming".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(chat.pending_steers.is_empty()); + assert!(chat.queued_user_messages.is_empty()); + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[tokio::test] +async fn live_legacy_agent_message_after_item_completed_does_not_duplicate_assistant_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + complete_assistant_message( + &mut chat, + "msg-live", + "hello", + Some(MessagePhase::FinalAnswer), + ); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("hello")); + + chat.handle_codex_event(Event { + id: "legacy-live".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "hello".into(), + phase: Some(MessagePhase::FinalAnswer), + }), + }); + + assert!(drain_insert_history(&mut rx).is_empty()); +} + +#[test] +fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() { + let local_image = PathBuf::from("/tmp/local.png"); + let rendered = ChatWidget::rendered_user_message_event_from_inputs(&[ + UserInput::Text { + text: "hello ".to_string(), + text_elements: vec![TextElement::new((0..5).into(), None)], + }, + UserInput::Image { + image_url: "https://example.com/remote.png".to_string(), + }, + UserInput::LocalImage { + path: local_image.clone(), + }, + UserInput::Skill { + name: "demo".to_string(), + path: PathBuf::from("/tmp/skill/SKILL.md"), + }, + UserInput::Mention { + name: "repo".to_string(), + path: "app://repo".to_string(), + }, + UserInput::Text { + text: "world".to_string(), + text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string()))], + }, + ]); + + assert_eq!( + rendered, + ChatWidget::rendered_user_message_event_from_parts( + "hello world".to_string(), + vec![ + TextElement::new((0..5).into(), Some("hello".to_string())), + TextElement::new((6..11).into(), Some("planet".to_string())), + ], + vec![local_image], + vec!["https://example.com/remote.png".to_string()], + ) + ); +} + +#[tokio::test] +async fn item_completed_only_pops_front_pending_steer() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.pending_steers.push_back(pending_steer("first")); + chat.pending_steers.push_back(pending_steer("second")); + chat.refresh_pending_input_preview(); + + complete_user_message(&mut chat, "user-other", "other"); + + assert_eq!(chat.pending_steers.len(), 2); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "first" + ); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("other")); + + complete_user_message(&mut chat, "user-first", "first"); + + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "second" + ); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("first")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + + let temp = tempdir().expect("tempdir"); + let image_path = temp.path().join("pending-steer.png"); + const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, + 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, + 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + std::fs::write(&image_path, TINY_PNG_BYTES).expect("write image"); + + let text = "note".to_string(); + let text_elements = vec![TextElement::new((0..4).into(), Some("note".to_string()))]; + chat.submit_user_message(UserMessage { + text: text.clone(), + local_images: vec![LocalImageAttachment { + placeholder: "[Image #1]".to_string(), + path: image_path, + }], + remote_image_urls: Vec::new(), + text_elements, + mention_bindings: Vec::new(), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + assert_eq!(chat.pending_steers.len(), 1); + let pending = chat.pending_steers.front().unwrap(); + assert_eq!(pending.user_message.local_images.len(), 1); + assert_eq!(pending.user_message.text_elements.len(), 1); + assert_eq!(pending.compare_key.message, text); + assert_eq!(pending.compare_key.image_count, 1); + + complete_user_message_for_inputs( + &mut chat, + "user-1", + vec![ + UserInput::Image { + image_url: "data:image/png;base64,placeholder".to_string(), + }, + UserInput::Text { + text, + text_elements: Vec::new(), + }, + ], + ); + + assert!(chat.pending_steers.is_empty()); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images, stored_remote_image_urls) = + user_cell.expect("expected pending steer user history cell"); + assert_eq!(stored_message, "note"); + assert_eq!( + stored_elements, + vec![TextElement::new((0..4).into(), Some("note".to_string()))] + ); + assert_eq!(stored_images.len(), 1); + assert!(stored_images[0].ends_with("pending-steer.png")); + assert!(stored_remote_image_urls.is_empty()); } #[tokio::test] async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); // Simulate "dead mode" repro timing by keeping a final-answer stream active while the @@ -3532,19 +3983,16 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() .set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 2); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.pending_steers.len(), 2); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.pending_steers.front().unwrap().user_message.text, "first follow-up" ); assert_eq!( - chat.queued_user_messages.back().unwrap().text, + chat.pending_steers.back().unwrap().user_message.text, "second follow-up" ); - assert_no_submit_op(&mut op_rx); - - // Completion must recover by submitting the oldest queued prompt first. - chat.on_task_complete(None, false); let first_items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, @@ -3557,17 +4005,6 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() text_elements: Vec::new(), }] ); - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "second follow-up" - ); - - // A subsequent turn lifecycle should continue draining remaining queued prompts, proving - // the widget did not enter a permanently stuck state. - chat.on_task_started(); - chat.on_task_complete(None, false); - let second_items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), @@ -3579,7 +4016,208 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() text_elements: Vec::new(), }] ); + assert!(drain_insert_history(&mut rx).is_empty()); + + complete_user_message(&mut chat, "user-1", "first follow-up"); + + assert_eq!(chat.pending_steers.len(), 1); + assert_eq!( + chat.pending_steers.front().unwrap().user_message.text, + "second follow-up" + ); + let first_insert = drain_insert_history(&mut rx); + assert_eq!(first_insert.len(), 1); + assert!(lines_to_single_string(&first_insert[0]).contains("first follow-up")); + + complete_user_message(&mut chat, "user-2", "second follow-up"); + + assert!(chat.pending_steers.is_empty()); + let second_insert = drain_insert_history(&mut rx); + assert_eq!(second_insert.len(), 1); + assert!(lines_to_single_string(&second_insert[0]).contains("second follow-up")); +} + +#[tokio::test] +async fn manual_interrupt_restores_pending_steers_to_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta( + "Final answer line +" + .to_string(), + ); + + chat.bottom_pane.set_composer_text( + "queued while streaming".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.pending_steers.len(), 1); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued while streaming".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!(chat.pending_steers.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), "queued while streaming"); + assert_no_submit_op(&mut op_rx); + + let inserted = drain_insert_history(&mut rx); + assert!( + inserted + .iter() + .all(|cell| !lines_to_single_string(cell).contains("queued while streaming")) + ); +} + +#[tokio::test] +async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta("Final answer line\n".to_string()); + + let mention_bindings = vec![MentionBinding { + mention: "figma".to_string(), + path: "/tmp/skills/figma/SKILL.md".to_string(), + }]; + chat.bottom_pane.set_composer_text_with_mention_bindings( + "please use $figma".to_string(), + vec![TextElement::new( + (11..17).into(), + Some("$figma".to_string()), + )], + Vec::new(), + mention_bindings.clone(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "please use $figma".to_string(), + text_elements: vec![TextElement::new( + (11..17).into(), + Some("$figma".to_string()), + )], + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert_eq!(chat.bottom_pane.composer_text(), "please use $figma"); + assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn manual_interrupt_restores_pending_steers_before_queued_messages() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta( + "Final answer line +" + .to_string(), + ); + + chat.bottom_pane + .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.queued_user_messages + .push_back(UserMessage::from("queued draft".to_string())); + chat.refresh_pending_input_preview(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "pending steer".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!(chat.pending_steers.is_empty()); assert!(chat.queued_user_messages.is_empty()); + assert_eq!( + chat.bottom_pane.composer_text(), + "pending steer +queued draft" + ); + assert_no_submit_op(&mut op_rx); +} + +#[tokio::test] +async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.on_agent_message_delta( + "Final answer line +" + .to_string(), + ); + + chat.bottom_pane + .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.queued_user_messages + .push_back(UserMessage::from("queued draft".to_string())); + chat.refresh_pending_input_preview(); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "pending steer".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected Op::UserTurn, got {other:?}"), + } + assert!(drain_insert_history(&mut rx).is_empty()); + + chat.handle_codex_event(Event { + id: "replaced".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Replaced, + }), + }); + + assert!(chat.pending_steers.is_empty()); + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.bottom_pane.composer_text(), ""); + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued draft".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued draft Op::UserTurn, got {other:?}"), + } } #[tokio::test] @@ -3998,13 +4636,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core"); terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", ""); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Final response.".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-1", "Final response.", None); chat.handle_codex_event(Event { id: "turn-1".into(), msg: EventMsg::TurnComplete(TurnCompleteEvent { @@ -4805,7 +5437,7 @@ async fn slash_copy_state_clears_on_thread_rollback() { async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { + chat.handle_codex_event_replay(Event { id: "turn-1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Legacy final message".into(), @@ -7110,7 +7742,7 @@ async fn interrupt_restores_queued_messages_into_composer() { .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). chat.handle_codex_event(Event { @@ -7150,7 +7782,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); + chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { id: "turn-1".into(), @@ -8395,22 +9027,10 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { }); // First finalized assistant message - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "First message".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-first", "First message", None); // Second finalized assistant message in the same turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Second message".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-second", "Second message", None); // End turn chat.handle_codex_event(Event { @@ -8450,13 +9070,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { text: "I will first analyze the request.".into(), }), }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-result", "Here is the result.", None); // Drain history and snapshot the combined visible content. let cells = drain_insert_history(&mut rx); @@ -8467,83 +9081,18 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { assert_snapshot!(combined); } -#[tokio::test] -async fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Stream some reasoning deltas first. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "I will ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "first analyze the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "request.".into(), - }), - }); - - // Then stream answer deltas, followed by the exact same final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Here is the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "result.".into(), - }), - }); - - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - phase: None, - }), - }); - - // Snapshot the combined visible content to ensure we render as expected - // when deltas are followed by the identical final message. - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!(combined); -} - // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. // This renders the final visual as seen in a terminal: history above, then a blank line, // then the exec block, another blank line, the status line, a blank line, and the composer. #[tokio::test] async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: - "I’m going to search the repo for where “Change Approved” is rendered to update that view." - .into(), - phase: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-search", + "I’m going to search the repo for where “Change Approved” is rendered to update that view.", + None, + ); let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; let parsed_cmd = vec![ @@ -8757,6 +9306,37 @@ async fn chatwidget_tall() { assert_snapshot!(term.backend().vt100().screen().contents()); } +#[tokio::test] +async fn enter_queues_user_messages_while_review_is_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + + chat.handle_codex_event(Event { + id: "review-1".into(), + msg: EventMsg::EnteredReviewMode(ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: Some("current changes".to_string()), + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.bottom_pane.set_composer_text( + "Queued while /review is running.".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "Queued while /review is running." + ); + assert!(chat.pending_steers.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!(drain_insert_history(&mut rx).is_empty()); +} + #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; From 935754baa353f13f76149f55c11a3b362a6089db Mon Sep 17 00:00:00 2001 From: joeytrasatti-openai Date: Tue, 3 Mar 2026 15:56:11 -0800 Subject: [PATCH 02/23] Add thread metadata update endpoint to app server (#13280) ## Summary - add the v2 `thread/metadata/update` API, including protocol/schema/TypeScript exports and app-server docs - patch stored thread `gitInfo` in sqlite without resuming the thread, with validation plus support for explicit `null` clears - repair missing sqlite thread rows from rollout data before patching, and make those repairs safe by inserting only when absent and updating only git columns so newer metadata is not clobbered - keep sqlite authoritative for mutable thread git metadata by preserving existing sqlite git fields during reconcile/backfill and only using rollout `SessionMeta` git fields to fill gaps - add regression coverage for the endpoint, repair paths, concurrent sqlite writes, clearing git fields, and rollout/backfill reconciliation - fix the login server shutdown race so cancelling before the waiter starts still terminates `block_until_done()` correctly ## Testing - `cargo test -p codex-state apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields` - `cargo test -p codex-state update_thread_git_info_preserves_newer_non_git_metadata` - `cargo test -p codex-core backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields` - `cargo test -p codex-app-server thread_metadata_update` - `cargo test` - currently fails in existing `codex-core` grep-files tests with `unsupported call: grep_files`: - `suite::grep_files::grep_files_tool_collects_matches` - `suite::grep_files::grep_files_tool_reports_empty_results` --- .../schema/json/ClientRequest.json | 72 + .../codex_app_server_protocol.schemas.json | 87 + .../codex_app_server_protocol.v2.schemas.json | 87 + .../json/v2/ThreadMetadataUpdateParams.json | 52 + .../json/v2/ThreadMetadataUpdateResponse.json | 1666 +++++++++++++++++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/ThreadMetadataGitInfoUpdateParams.ts | 20 + .../v2/ThreadMetadataUpdateParams.ts | 12 + .../v2/ThreadMetadataUpdateResponse.ts | 6 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 55 + codex-rs/app-server/Cargo.toml | 1 - codex-rs/app-server/README.md | 29 + .../app-server/src/codex_message_processor.rs | 308 +++ .../app-server/tests/common/mcp_process.rs | 12 +- .../tests/suite/fuzzy_file_search.rs | 2 +- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_metadata_update.rs | 462 +++++ .../app-server/tests/suite/v2/turn_start.rs | 2 +- codex-rs/core/src/rollout/metadata.rs | 64 +- codex-rs/core/src/state_db.rs | 3 + codex-rs/state/src/model/thread_metadata.rs | 13 + codex-rs/state/src/runtime/threads.rs | 293 +++ 24 files changed, 3251 insertions(+), 6 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 01790fb98f..40745ce666 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2059,6 +2059,54 @@ }, "type": "object" }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadReadParams": { "properties": { "includeTurns": { @@ -2939,6 +2987,30 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/metadata/update" + ], + "title": "Thread/metadata/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMetadataUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/metadata/updateRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ddeee0ce73..4695d2a810 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -523,6 +523,30 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/metadata/update" + ], + "title": "Thread/metadata/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMetadataUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/metadata/updateRequest", + "type": "object" + }, { "properties": { "id": { @@ -13611,6 +13635,69 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" + }, + "ThreadMetadataUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" + }, "ThreadNameUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 1bd5733364..e1f4c85d13 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1012,6 +1012,30 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/metadata/update" + ], + "title": "Thread/metadata/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMetadataUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/metadata/updateRequest", + "type": "object" + }, { "properties": { "id": { @@ -12289,6 +12313,69 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ThreadMetadataUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" + }, + "ThreadMetadataUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" + }, "ThreadNameUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json new file mode 100644 index 0000000000..c6679568ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateParams.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadMetadataGitInfoUpdateParams": { + "properties": { + "branch": { + "description": "Omit to leave the stored branch unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "description": "Omit to leave the stored origin URL unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + }, + "sha": { + "description": "Omit to leave the stored commit unchanged, set to `null` to clear it, or provide a non-empty string to replace it.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadMetadataGitInfoUpdateParams" + }, + { + "type": "null" + } + ], + "description": "Patch the stored Git metadata for this thread. Omit a field to leave it unchanged, set it to `null` to clear it, or provide a string to replace the stored value." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMetadataUpdateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json new file mode 100644 index 0000000000..800010ee2a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -0,0 +1,1666 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": [ + "review", + "compact", + "memory_consolidation" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": [ + "depth", + "parent_thread_id" + ], + "type": "object" + } + }, + "required": [ + "thread_spawn" + ], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "type": "string" + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "id": { + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory.", + "type": "string" + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadMetadataUpdateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index f6df83b6e4..379ab414c2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -32,6 +32,7 @@ import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; +import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams"; import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; @@ -47,4 +48,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts new file mode 100644 index 0000000000..7424ae3606 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataGitInfoUpdateParams.ts @@ -0,0 +1,20 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMetadataGitInfoUpdateParams = { +/** + * Omit to leave the stored commit unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +sha?: string | null, +/** + * Omit to leave the stored branch unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +branch?: string | null, +/** + * Omit to leave the stored origin URL unchanged, set to `null` to clear it, + * or provide a non-empty string to replace it. + */ +originUrl?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts new file mode 100644 index 0000000000..5b6eb8a05f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; + +export type ThreadMetadataUpdateParams = { threadId: string, +/** + * Patch the stored Git metadata for this thread. + * Omit a field to leave it unchanged, set it to `null` to clear it, or + * provide a string to replace the stored value. + */ +gitInfo?: ThreadMetadataGitInfoUpdateParams | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts new file mode 100644 index 0000000000..d9c09ef2d3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMetadataUpdateResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Thread } from "./Thread"; + +export type ThreadMetadataUpdateResponse = { thread: Thread, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 757eb72893..9c677641fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -179,6 +179,9 @@ export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse"; +export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; +export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams"; +export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse"; export type { ThreadNameUpdatedNotification } from "./ThreadNameUpdatedNotification"; export type { ThreadReadParams } from "./ThreadReadParams"; export type { ThreadReadResponse } from "./ThreadReadResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 98efcdd290..67968507c7 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -211,6 +211,10 @@ client_request_definitions! { params: v2::ThreadSetNameParams, response: v2::ThreadSetNameResponse, }, + ThreadMetadataUpdate => "thread/metadata/update" { + params: v2::ThreadMetadataUpdateParams, + response: v2::ThreadMetadataUpdateResponse, + }, ThreadUnarchive => "thread/unarchive" { params: v2::ThreadUnarchiveParams, response: v2::ThreadUnarchiveResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8f0b37beef..6b26433ae0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2068,6 +2068,61 @@ pub struct ThreadUnarchiveParams { #[ts(export_to = "v2/")] pub struct ThreadSetNameResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateParams { + pub thread_id: String, + /// Patch the stored Git metadata for this thread. + /// Omit a field to leave it unchanged, set it to `null` to clear it, or + /// provide a string to replace the stored value. + #[ts(optional = nullable)] + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataGitInfoUpdateParams { + /// Omit to leave the stored commit unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "super::serde_helpers::serialize_double_option", + deserialize_with = "super::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub sha: Option>, + /// Omit to leave the stored branch unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "super::serde_helpers::serialize_double_option", + deserialize_with = "super::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub branch: Option>, + /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "super::serde_helpers::serialize_double_option", + deserialize_with = "super::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub origin_url: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateResponse { + pub thread: Thread, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ba2355f675..ca7f7bffb7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -66,7 +66,6 @@ axum = { workspace = true, default-features = false, features = [ ] } base64 = { workspace = true } core_test_support = { workspace = true } -codex-state = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9ec4821e8d..d2d8aa5445 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -126,6 +126,7 @@ Example with notification opt-out: - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`. @@ -324,6 +325,34 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl } } ``` +### Example: Update stored thread metadata + +Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. + +```json +{ "method": "thread/metadata/update", "id": 24, "params": { + "threadId": "thr_123", + "gitInfo": { "branch": "feature/sidebar-pr" } +} } +{ "id": 24, "result": { + "thread": { + "id": "thr_123", + "gitInfo": { "sha": null, "branch": "feature/sidebar-pr", "originUrl": null } + } +} } + +{ "method": "thread/metadata/update", "id": 25, "params": { + "threadId": "thr_123", + "gitInfo": { "branch": null } +} } +{ "id": 25, "result": { + "thread": { + "id": "thr_123", + "gitInfo": null + } +} } +``` + ### Example: Archive a thread Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1c18345559..f154bff311 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -110,6 +110,9 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; @@ -201,6 +204,7 @@ use codex_core::skills::remote::export_remote_skill; use codex_core::skills::remote::list_remote_skills; use codex_core::state_db::StateDbHandle; use codex_core::state_db::get_state_db; +use codex_core::state_db::reconcile_rollout; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; use codex_core::windows_sandbox::WindowsSandboxSetupRequest; @@ -239,6 +243,8 @@ use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_rmcp_client::perform_oauth_login_return_url; +use codex_state::StateRuntime; +use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; @@ -597,6 +603,10 @@ impl CodexMessageProcessor { self.thread_set_name(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadMetadataUpdate { request_id, params } => { + self.thread_metadata_update(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadUnarchive { request_id, params } => { self.thread_unarchive(to_connection_request_id(request_id), params) .await; @@ -1924,6 +1934,304 @@ impl CodexMessageProcessor { .await; } + async fn thread_metadata_update( + &self, + request_id: ConnectionRequestId, + params: ThreadMetadataUpdateParams, + ) { + let ThreadMetadataUpdateParams { + thread_id, + git_info, + } = params; + + let thread_uuid = match ThreadId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; + + let Some(ThreadMetadataGitInfoUpdateParams { + sha, + branch, + origin_url, + }) = git_info + else { + self.send_invalid_request_error( + request_id, + "gitInfo must include at least one field".to_string(), + ) + .await; + return; + }; + + if sha.is_none() && branch.is_none() && origin_url.is_none() { + self.send_invalid_request_error( + request_id, + "gitInfo must include at least one field".to_string(), + ) + .await; + return; + } + + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); + let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db()); + if state_db_ctx.is_none() { + state_db_ctx = get_state_db(&self.config, None).await; + } + let Some(state_db_ctx) = state_db_ctx else { + self.send_internal_error( + request_id, + format!("sqlite state db unavailable for thread {thread_uuid}"), + ) + .await; + return; + }; + + if let Err(error) = self + .ensure_thread_metadata_row_exists(thread_uuid, &state_db_ctx, loaded_thread.as_ref()) + .await + { + self.outgoing.send_error(request_id, error).await; + return; + } + + let git_sha = match sha { + Some(Some(sha)) => { + let sha = sha.trim().to_string(); + if sha.is_empty() { + self.send_invalid_request_error( + request_id, + "gitInfo.sha must not be empty".to_string(), + ) + .await; + return; + } + Some(Some(sha)) + } + Some(None) => Some(None), + None => None, + }; + let git_branch = match branch { + Some(Some(branch)) => { + let branch = branch.trim().to_string(); + if branch.is_empty() { + self.send_invalid_request_error( + request_id, + "gitInfo.branch must not be empty".to_string(), + ) + .await; + return; + } + Some(Some(branch)) + } + Some(None) => Some(None), + None => None, + }; + let git_origin_url = match origin_url { + Some(Some(origin_url)) => { + let origin_url = origin_url.trim().to_string(); + if origin_url.is_empty() { + self.send_invalid_request_error( + request_id, + "gitInfo.originUrl must not be empty".to_string(), + ) + .await; + return; + } + Some(Some(origin_url)) + } + Some(None) => Some(None), + None => None, + }; + + let updated = match state_db_ctx + .update_thread_git_info( + thread_uuid, + git_sha.as_ref().map(|value| value.as_deref()), + git_branch.as_ref().map(|value| value.as_deref()), + git_origin_url.as_ref().map(|value| value.as_deref()), + ) + .await + { + Ok(updated) => updated, + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to update thread metadata for {thread_uuid}: {err}"), + ) + .await; + return; + } + }; + if !updated { + self.send_internal_error( + request_id, + format!("thread metadata disappeared before update completed: {thread_uuid}"), + ) + .await; + return; + } + + let Some(summary) = + read_summary_from_state_db_context_by_thread_id(Some(&state_db_ctx), thread_uuid).await + else { + self.send_internal_error( + request_id, + format!("failed to reload updated thread metadata for {thread_uuid}"), + ) + .await; + return; + }; + + let mut thread = summary_to_thread(summary); + self.attach_thread_name(thread_uuid, &mut thread).await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + false, + ); + + self.outgoing + .send_response(request_id, ThreadMetadataUpdateResponse { thread }) + .await; + } + + async fn ensure_thread_metadata_row_exists( + &self, + thread_uuid: ThreadId, + state_db_ctx: &Arc, + loaded_thread: Option<&Arc>, + ) -> Result<(), JSONRPCErrorError> { + fn invalid_request(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + } + } + + fn internal_error(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message, + data: None, + } + } + + match state_db_ctx.get_thread(thread_uuid).await { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(err) => { + return Err(internal_error(format!( + "failed to load thread metadata for {thread_uuid}: {err}" + ))); + } + } + + if let Some(thread) = loaded_thread { + let Some(rollout_path) = thread.rollout_path() else { + return Err(invalid_request(format!( + "ephemeral thread does not support metadata updates: {thread_uuid}" + ))); + }; + + reconcile_rollout( + Some(state_db_ctx), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + None, + &[], + None, + None, + ) + .await; + + match state_db_ctx.get_thread(thread_uuid).await { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(err) => { + return Err(internal_error(format!( + "failed to load reconciled thread metadata for {thread_uuid}: {err}" + ))); + } + } + + let config_snapshot = thread.config_snapshot().await; + let model_provider = config_snapshot.model_provider_id.clone(); + let mut builder = ThreadMetadataBuilder::new( + thread_uuid, + rollout_path, + Utc::now(), + config_snapshot.session_source.clone(), + ); + builder.model_provider = Some(model_provider.clone()); + builder.cwd = config_snapshot.cwd.clone(); + builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string()); + builder.sandbox_policy = config_snapshot.sandbox_policy.clone(); + builder.approval_mode = config_snapshot.approval_policy; + let metadata = builder.build(model_provider.as_str()); + if let Err(err) = state_db_ctx.insert_thread_if_absent(&metadata).await { + return Err(internal_error(format!( + "failed to create thread metadata for {thread_uuid}: {err}" + ))); + } + return Ok(()); + } + + let rollout_path = + match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) + .await + { + Ok(Some(path)) => path, + Ok(None) => match find_archived_thread_path_by_id_str( + &self.config.codex_home, + &thread_uuid.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + return Err(invalid_request(format!("thread not found: {thread_uuid}"))); + } + Err(err) => { + return Err(internal_error(format!( + "failed to locate archived thread id {thread_uuid}: {err}" + ))); + } + }, + Err(err) => { + return Err(internal_error(format!( + "failed to locate thread id {thread_uuid}: {err}" + ))); + } + }; + + reconcile_rollout( + Some(state_db_ctx), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + None, + &[], + None, + None, + ) + .await; + + match state_db_ctx.get_thread(thread_uuid).await { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(internal_error(format!( + "failed to create thread metadata from rollout for {thread_uuid}" + ))), + Err(err) => Err(internal_error(format!( + "failed to load reconciled thread metadata for {thread_uuid}: {err}" + ))), + } + } + async fn thread_unarchive( &mut self, request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 4b525878b8..753816b8d2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -44,6 +44,7 @@ use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; use codex_app_server_protocol::ThreadRealtimeAppendTextParams; @@ -100,7 +101,7 @@ impl McpProcess { cmd.stderr(Stdio::piped()); cmd.current_dir(codex_home); cmd.env("CODEX_HOME", codex_home); - cmd.env("RUST_LOG", "debug"); + cmd.env("RUST_LOG", "info"); cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); for (k, v) in env_overrides { @@ -333,6 +334,15 @@ impl McpProcess { self.send_request("thread/name/set", params).await } + /// Send a `thread/metadata/update` JSON-RPC request. + pub async fn send_thread_metadata_update_request( + &mut self, + params: ThreadMetadataUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/metadata/update", params).await + } + /// Send a `thread/unsubscribe` JSON-RPC request. pub async fn send_thread_unsubscribe_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index b6932560f0..81b965319c 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -434,7 +434,7 @@ async fn test_fuzzy_file_search_session_update_after_stop_fails() -> Result<()> async fn test_fuzzy_file_search_session_stops_sending_updates_after_stop() -> Result<()> { let codex_home = TempDir::new()?; let root = TempDir::new()?; - for i in 0..10_000 { + for i in 0..2_000 { let file_path = root.path().join(format!("file-{i:04}.txt")); std::fs::write(file_path, "contents")?; } diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index cb3acb633d..7b7010086a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -24,6 +24,7 @@ mod thread_archive; mod thread_fork; mod thread_list; mod thread_loaded_list; +mod thread_metadata_update; mod thread_read; mod thread_resume; mod thread_rollback; diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs new file mode 100644 index 0000000000..50917ee8a7 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -0,0 +1,462 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::rollout_path; +use app_test_support::to_response; +use codex_app_server_protocol::GitInfo; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::state_db::reconcile_rollout; +use codex_protocol::ThreadId; +use codex_protocol::protocol::GitInfo as RolloutGitInfo; +use codex_state::StateRuntime; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/sidebar-pr".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let update_result = update_resp.result.clone(); + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread.id); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/sidebar-pr".to_string()), + origin_url: None, + }) + ); + assert_eq!(updated.status, ThreadStatus::Idle); + let updated_thread_json = update_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/metadata/update result.thread must be an object"); + let updated_git_info_json = updated_thread_json + .get("gitInfo") + .and_then(Value::as_object) + .expect("thread/metadata/update must serialize `thread.gitInfo` on the wire"); + assert_eq!( + updated_git_info_json.get("branch").and_then(Value::as_str), + Some("feature/sidebar-pr") + ); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread: read } = to_response::(read_resp)?; + + assert_eq!( + read.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/sidebar-pr".to_string()), + origin_url: None, + }) + ); + assert_eq!(read.status, ThreadStatus::Idle); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id, + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: None, + origin_url: None, + }), + }) + .await?; + let update_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(update_id)), + ) + .await??; + + assert_eq!( + update_err.error.message, + "gitInfo must include at least one field" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let _state_db = init_state_db(codex_home.path()).await?; + + let preview = "Stored thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/stored-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736078400); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/stored-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let state_db = init_state_db(codex_home.path()).await?; + + let preview = "Loaded thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-30-00", + "2025-01-06T08:30:00Z", + preview, + Some("mock_provider"), + None, + )?; + let thread_uuid = ThreadId::from_string(&thread_id)?; + let rollout_path = rollout_path(codex_home.path(), "2025-01-06T08-30-00", &thread_id); + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + "mock_provider", + None, + &[], + None, + None, + ) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let _: ThreadResumeResponse = to_response::(resume_resp)?; + + assert_eq!(state_db.delete_thread(thread_uuid).await?, 1); + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/loaded-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736152200); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/loaded-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let _state_db = init_state_db(codex_home.path()).await?; + + let preview = "Archived thread preview"; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-06T08-30-00", + "2025-01-06T08:30:00Z", + preview, + Some("mock_provider"), + None, + )?; + + let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + fs::create_dir_all(&archived_dir)?; + let archived_source = rollout_path(codex_home.path(), "2025-01-06T08-30-00", &thread_id); + let archived_dest = archived_dir.join( + archived_source + .file_name() + .expect("archived rollout should have a file name"), + ); + fs::rename(&archived_source, &archived_dest)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/archived-thread".to_string())), + origin_url: None, + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id); + assert_eq!(updated.preview, preview); + assert_eq!(updated.created_at, 1736152200); + assert_eq!( + updated.git_info, + Some(GitInfo { + sha: None, + branch: Some("feature/archived-thread".to_string()), + origin_url: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-07T09-15-00", + "2025-01-07T09:15:00Z", + "Thread preview", + Some("mock_provider"), + Some(RolloutGitInfo { + commit_hash: Some("abc123".to_string()), + branch: Some("feature/sidebar-pr".to_string()), + repository_url: Some("git@example.com:openai/codex.git".to_string()), + }), + )?; + let _state_db = init_state_db(codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread_id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: Some(None), + branch: Some(None), + origin_url: Some(None), + }), + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let ThreadMetadataUpdateResponse { thread: updated } = + to_response::(update_resp)?; + + assert_eq!(updated.id, thread_id.clone()); + assert_eq!(updated.git_info, None); + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread: read } = to_response::(read_resp)?; + + assert_eq!(read.git_info, None); + + Ok(()) +} + +async fn init_state_db(codex_home: &Path) -> Result> { + let state_db = + StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into(), None).await?; + state_db.mark_backfill_complete(None).await?; + Ok(state_db) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +suppress_unstable_features_warning = true + +[features] +sqlite = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0888d0f23e..15b309ddf4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -62,7 +62,7 @@ use tempfile::TempDir; use tokio::time::timeout; #[cfg(windows)] -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 0bee69b6aa..66ca9248eb 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -282,6 +282,9 @@ pub(crate) async fn backfill_sessions( let mut metadata = outcome.metadata; metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd); let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string()); + if let Ok(Some(existing_metadata)) = runtime.get_thread(metadata.id).await { + metadata.prefer_existing_git_info(&existing_metadata); + } if rollout.archived && metadata.archived_at.is_none() { let fallback_archived_at = metadata.updated_at; metadata.archived_at = file_modified_time_utc(&rollout.path) @@ -503,6 +506,7 @@ mod tests { use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::protocol::CompactedItem; + use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; @@ -669,12 +673,14 @@ mod tests { "2026-01-27T12-34-56", "2026-01-27T12:34:56Z", first_uuid, + None, ); let second_path = write_rollout_in_sessions( codex_home.as_path(), "2026-01-27T12-35-56", "2026-01-27T12:35:56Z", second_uuid, + None, ); let runtime = @@ -730,6 +736,58 @@ mod tests { assert!(state.last_success_at.is_some()); } + #[tokio::test] + async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields() { + let dir = tempdir().expect("tempdir"); + let codex_home = dir.path().to_path_buf(); + let thread_uuid = Uuid::new_v4(); + let rollout_path = write_rollout_in_sessions( + codex_home.as_path(), + "2026-01-27T12-34-56", + "2026-01-27T12:34:56Z", + thread_uuid, + Some(GitInfo { + commit_hash: Some("rollout-sha".to_string()), + branch: Some("rollout-branch".to_string()), + repository_url: Some("git@example.com:openai/codex.git".to_string()), + }), + ); + + let runtime = + codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); + let mut existing = extract_metadata_from_rollout(&rollout_path, "test-provider", None) + .await + .expect("extract") + .metadata; + existing.git_sha = None; + existing.git_branch = Some("sqlite-branch".to_string()); + existing.git_origin_url = None; + runtime + .upsert_thread(&existing) + .await + .expect("existing metadata upsert"); + + let mut config = crate::config::test_config(); + config.codex_home = codex_home.clone(); + config.model_provider_id = "test-provider".to_string(); + backfill_sessions(runtime.as_ref(), &config, None).await; + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("get thread") + .expect("thread exists"); + assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha")); + assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); + assert_eq!( + persisted.git_origin_url.as_deref(), + Some("git@example.com:openai/codex.git") + ); + } + #[tokio::test] async fn backfill_sessions_normalizes_cwd_before_upsert() { let dir = tempdir().expect("tempdir"); @@ -742,6 +800,7 @@ mod tests { "2026-01-27T12:34:56Z", thread_uuid, session_cwd.clone(), + None, ); let runtime = @@ -770,6 +829,7 @@ mod tests { filename_ts: &str, event_ts: &str, thread_uuid: Uuid, + git: Option, ) -> PathBuf { write_rollout_in_sessions_with_cwd( codex_home, @@ -777,6 +837,7 @@ mod tests { event_ts, thread_uuid, codex_home.to_path_buf(), + git, ) } @@ -786,6 +847,7 @@ mod tests { event_ts: &str, thread_uuid: Uuid, cwd: PathBuf, + git: Option, ) -> PathBuf { let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); let sessions_dir = codex_home.join("sessions"); @@ -808,7 +870,7 @@ mod tests { }; let session_meta_line = SessionMetaLine { meta: session_meta, - git: None, + git, }; let rollout_line = RolloutLine { timestamp: event_ts.to_string(), diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 43641402fe..953c7a8e12 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -390,6 +390,9 @@ pub async fn reconcile_rollout( let mut metadata = outcome.metadata; let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string()); metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd); + if let Ok(Some(existing_metadata)) = ctx.get_thread(metadata.id).await { + metadata.prefer_existing_git_info(&existing_metadata); + } match archived_only { Some(true) if metadata.archived_at.is_none() => { metadata.archived_at = Some(metadata.updated_at); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 9616268012..c4362a8df2 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -197,6 +197,19 @@ impl ThreadMetadataBuilder { } impl ThreadMetadata { + /// Preserve existing non-null Git fields when rollout-derived metadata is reconciled. + pub fn prefer_existing_git_info(&mut self, existing: &Self) { + if existing.git_sha.is_some() { + self.git_sha = existing.git_sha.clone(); + } + if existing.git_branch.is_some() { + self.git_branch = existing.git_branch.clone(); + } + if existing.git_origin_url.is_some() { + self.git_origin_url = existing.git_origin_url.clone(); + } + } + /// Return the list of field names that differ between `self` and `other`. pub fn diff_fields(&self, other: &Self) -> Vec<&'static str> { let mut diffs = Vec::new(); diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index f9e939a4a1..2a1f44e1e9 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -207,6 +207,64 @@ FROM threads .await } + pub async fn insert_thread_if_absent( + &self, + metadata: &crate::ThreadMetadata, + ) -> anyhow::Result { + let result = sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + source, + agent_nickname, + agent_role, + model_provider, + cwd, + cli_version, + title, + sandbox_policy, + approval_mode, + tokens_used, + first_user_message, + archived, + archived_at, + git_sha, + git_branch, + git_origin_url, + memory_mode +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO NOTHING + "#, + ) + .bind(metadata.id.to_string()) + .bind(metadata.rollout_path.display().to_string()) + .bind(datetime_to_epoch_seconds(metadata.created_at)) + .bind(datetime_to_epoch_seconds(metadata.updated_at)) + .bind(metadata.source.as_str()) + .bind(metadata.agent_nickname.as_deref()) + .bind(metadata.agent_role.as_deref()) + .bind(metadata.model_provider.as_str()) + .bind(metadata.cwd.display().to_string()) + .bind(metadata.cli_version.as_str()) + .bind(metadata.title.as_str()) + .bind(metadata.sandbox_policy.as_str()) + .bind(metadata.approval_mode.as_str()) + .bind(metadata.tokens_used) + .bind(metadata.first_user_message.as_deref().unwrap_or_default()) + .bind(metadata.archived_at.is_some()) + .bind(metadata.archived_at.map(datetime_to_epoch_seconds)) + .bind(metadata.git_sha.as_deref()) + .bind(metadata.git_branch.as_deref()) + .bind(metadata.git_origin_url.as_deref()) + .bind("enabled") + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn set_thread_memory_mode( &self, thread_id: ThreadId, @@ -220,6 +278,35 @@ FROM threads Ok(result.rows_affected() > 0) } + pub async fn update_thread_git_info( + &self, + thread_id: ThreadId, + git_sha: Option>, + git_branch: Option>, + git_origin_url: Option>, + ) -> anyhow::Result { + let result = sqlx::query( + r#" +UPDATE threads +SET + git_sha = CASE WHEN ? THEN ? ELSE git_sha END, + git_branch = CASE WHEN ? THEN ? ELSE git_branch END, + git_origin_url = CASE WHEN ? THEN ? ELSE git_origin_url END +WHERE id = ? + "#, + ) + .bind(git_sha.is_some()) + .bind(git_sha.flatten()) + .bind(git_branch.is_some()) + .bind(git_branch.flatten()) + .bind(git_origin_url.is_some()) + .bind(git_origin_url.flatten()) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + Ok(result.rows_affected() > 0) + } + async fn upsert_thread_with_creation_memory_mode( &self, metadata: &crate::ThreadMetadata, @@ -361,6 +448,9 @@ ON CONFLICT(thread_id, position) DO NOTHING for item in items { apply_rollout_item(&mut metadata, item, &self.default_provider); } + if let Some(existing_metadata) = existing_metadata.as_ref() { + metadata.prefer_existing_git_info(existing_metadata); + } if let Some(updated_at) = file_modified_time_utc(builder.rollout_path.as_path()).await { metadata.updated_at = updated_at; } @@ -559,6 +649,7 @@ mod tests { use super::*; use crate::runtime::test_support::test_thread_metadata; use crate::runtime::test_support::unique_temp_dir; + use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; @@ -654,4 +745,206 @@ mod tests { .expect("memory mode should load"); assert_eq!(memory_mode.as_deref(), Some("polluted")); } + + #[tokio::test] + async fn apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000457").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.git_branch = Some("sqlite-branch".to_string()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let created_at = metadata.created_at.to_rfc3339(); + let builder = ThreadMetadataBuilder::new( + thread_id, + metadata.rollout_path.clone(), + metadata.created_at, + SessionSource::Cli, + ); + let items = vec![RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: created_at, + cwd: PathBuf::new(), + originator: String::new(), + cli_version: String::new(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + model_provider: None, + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: Some(GitInfo { + commit_hash: Some("rollout-sha".to_string()), + branch: Some("rollout-branch".to_string()), + repository_url: Some("git@example.com:openai/codex.git".to_string()), + }), + })]; + + runtime + .apply_rollout_items(&builder, &items, None, None) + .await + .expect("apply_rollout_items should succeed"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha")); + assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch")); + assert_eq!( + persisted.git_origin_url.as_deref(), + Some("git@example.com:openai/codex.git") + ); + } + + #[tokio::test] + async fn update_thread_git_info_preserves_newer_non_git_metadata() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000789").expect("valid thread id"); + let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let updated_at = datetime_to_epoch_seconds( + DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), + ); + sqlx::query( + "UPDATE threads SET updated_at = ?, tokens_used = ?, first_user_message = ? WHERE id = ?", + ) + .bind(updated_at) + .bind(123_i64) + .bind("newer preview") + .bind(thread_id.to_string()) + .execute(runtime.pool.as_ref()) + .await + .expect("concurrent metadata write should succeed"); + + let updated = runtime + .update_thread_git_info( + thread_id, + Some(Some("abc123")), + Some(Some("feature/branch")), + Some(Some("git@example.com:openai/codex.git")), + ) + .await + .expect("git info update should succeed"); + assert!(updated, "git info update should touch the thread row"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.tokens_used, 123); + assert_eq!( + persisted.first_user_message.as_deref(), + Some("newer preview") + ); + assert_eq!(datetime_to_epoch_seconds(persisted.updated_at), updated_at); + assert_eq!(persisted.git_sha.as_deref(), Some("abc123")); + assert_eq!(persisted.git_branch.as_deref(), Some("feature/branch")); + assert_eq!( + persisted.git_origin_url.as_deref(), + Some("git@example.com:openai/codex.git") + ); + } + + #[tokio::test] + async fn insert_thread_if_absent_preserves_existing_metadata() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id"); + + let mut existing = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + existing.tokens_used = 123; + existing.first_user_message = Some("newer preview".to_string()); + existing.updated_at = DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"); + runtime + .upsert_thread(&existing) + .await + .expect("initial upsert should succeed"); + + let mut fallback = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + fallback.tokens_used = 0; + fallback.first_user_message = None; + fallback.updated_at = DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"); + + let inserted = runtime + .insert_thread_if_absent(&fallback) + .await + .expect("insert should succeed"); + assert!(!inserted, "existing rows should not be overwritten"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.tokens_used, 123); + assert_eq!( + persisted.first_user_message.as_deref(), + Some("newer preview") + ); + assert_eq!( + datetime_to_epoch_seconds(persisted.updated_at), + datetime_to_epoch_seconds(existing.updated_at) + ); + } + + #[tokio::test] + async fn update_thread_git_info_can_clear_fields() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("state db should initialize"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000790").expect("valid thread id"); + let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone()); + metadata.git_sha = Some("abc123".to_string()); + metadata.git_branch = Some("feature/branch".to_string()); + metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string()); + + runtime + .upsert_thread(&metadata) + .await + .expect("initial upsert should succeed"); + + let updated = runtime + .update_thread_git_info(thread_id, Some(None), Some(None), Some(None)) + .await + .expect("git info clear should succeed"); + assert!(updated, "git info clear should touch the thread row"); + + let persisted = runtime + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist"); + assert_eq!(persisted.git_sha, None); + assert_eq!(persisted.git_branch, None); + assert_eq!(persisted.git_origin_url, None); + } } From b92146d48b66798fc70f58952a68efc0f26b01e5 Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Tue, 3 Mar 2026 15:56:54 -0800 Subject: [PATCH 03/23] Add under-development original-resolution view_image support (#13050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add original-resolution support for `view_image` behind the under-development `view_image_original_resolution` feature flag. When the flag is enabled and the target model is `gpt-5.3-codex` or newer, `view_image` now preserves original PNG/JPEG/WebP bytes and sends `detail: "original"` to the Responses API instead of using the legacy resize/compress path. ## What changed - Added `view_image_original_resolution` as an under-development feature flag. - Added `ImageDetail` to the protocol models and support for serializing `detail: "original"` on tool-returned images. - Added `PromptImageMode::Original` to `codex-utils-image`. - Preserves original PNG/JPEG/WebP bytes. - Keeps legacy behavior for the resize path. - Updated `view_image` to: - use the shared `local_image_content_items_with_label_number(...)` helper in both code paths - select original-resolution mode only when: - the feature flag is enabled, and - the model slug parses as `gpt-5.3-codex` or newer - Kept local user image attachments on the existing resize path; this change is specific to `view_image`. - Updated history/image accounting so only `detail: "original"` images use the docs-based GPT-5 image cost calculation; legacy images still use the old fixed estimate. - Added JS REPL guidance, gated on the same feature flag, to prefer JPEG at 85% quality unless lossless is required, while still allowing other formats when explicitly requested. - Updated tests and helper code that construct `FunctionCallOutputContentItem::InputImage` to carry the new `detail` field. ## Behavior ### Feature off - `view_image` keeps the existing resize/re-encode behavior. - History estimation keeps the existing fixed-cost heuristic. ### Feature on + `gpt-5.3-codex+` - `view_image` sends original-resolution images with `detail: "original"`. - PNG/JPEG/WebP source bytes are preserved when possible. - History estimation uses the GPT-5 docs-based image-cost calculation for those `detail: "original"` images. #### [git stack](https://github.com/magus/git-stack-cli) - 👉 `1` https://github.com/openai/codex/pull/13050 - ⏳ `2` https://github.com/openai/codex/pull/13331 - ⏳ `3` https://github.com/openai/codex/pull/13049 --- codex-rs/Cargo.lock | 2 + .../schema/json/ClientRequest.json | 19 ++ .../schema/json/EventMsg.json | 19 ++ .../codex_app_server_protocol.schemas.json | 19 ++ .../codex_app_server_protocol.v2.schemas.json | 19 ++ .../RawResponseItemCompletedNotification.json | 19 ++ .../schema/json/v2/ThreadResumeParams.json | 19 ++ .../FunctionCallOutputContentItem.ts | 3 +- .../schema/typescript/ImageDetail.ts | 5 + .../schema/typescript/index.ts | 1 + .../app-server/tests/common/models_cache.rs | 1 + .../tests/suite/v2/dynamic_tools.rs | 5 +- codex-rs/codex-api/src/endpoint/models.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/Cargo.toml | 6 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/models.json | 12 ++ codex-rs/core/src/client.rs | 1 + codex-rs/core/src/context_manager/history.rs | 98 +++++++-- .../core/src/context_manager/history_tests.rs | 98 ++++++++- codex-rs/core/src/features.rs | 8 + codex-rs/core/src/models_manager/manager.rs | 22 +- .../core/src/models_manager/model_info.rs | 1 + codex-rs/core/src/project_doc.rs | 18 ++ codex-rs/core/src/tools/context.rs | 2 + .../core/src/tools/handlers/view_image.rs | 24 ++- codex-rs/core/src/tools/js_repl/mod.rs | 7 +- codex-rs/core/src/truncate.rs | 7 +- codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 203 ++++++++++++++++++ codex-rs/protocol/src/models.rs | 51 ++++- codex-rs/protocol/src/openai_models.rs | 4 + codex-rs/utils/image/src/lib.rs | 191 +++++++++++----- 37 files changed, 794 insertions(+), 107 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d9ee0f608b..b0c1e38429 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1820,8 +1820,10 @@ dependencies = [ "codex-state", "codex-test-macros", "codex-utils-absolute-path", + "codex-utils-cache", "codex-utils-cargo-bin", "codex-utils-home-dir", + "codex-utils-image", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 40745ce666..84ee3d5c1f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -514,6 +514,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -627,6 +637,15 @@ ], "type": "string" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "InitializeCapabilities": { "description": "Client-declared capabilities negotiated during initialize.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 4258bf7c2c..85de9c0dab 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -3478,6 +3478,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -3569,6 +3579,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "LocalShellAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4695d2a810..a60e6c4a44 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9700,6 +9700,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -9865,6 +9875,15 @@ ], "type": "string" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "InputModality": { "description": "Canonical user-input modality tags advertised by a model.", "oneOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e1f4c85d13..b4a5f9f6cb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6542,6 +6542,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -6828,6 +6838,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "InitializeCapabilities": { "description": "Client-declared capabilities negotiated during initialize.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 4717ff266b..cedfdb19c4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -103,6 +103,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -173,6 +183,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "LocalShellAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 353bda693d..91850f60e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -145,6 +145,16 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, "image_url": { "type": "string" }, @@ -215,6 +225,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "LocalShellAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts index 8bfb6993d0..fb2996f1e5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts @@ -1,9 +1,10 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "./ImageDetail"; /** * Responses API compatible content items that can be returned by a tool call. * This is a subset of ContentItem with the types we support as function call outputs. */ -export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, }; +export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts new file mode 100644 index 0000000000..a48f07c088 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ImageDetail = "auto" | "low" | "high" | "original"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index adaeb4c26c..228998cddb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -83,6 +83,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; export type { GitSha } from "./GitSha"; export type { HistoryEntry } from "./HistoryEntry"; +export type { ImageDetail } from "./ImageDetail"; export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 0de8fda5f2..0c2a2e5857 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -38,6 +38,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 52a597f66b..f3945c2b4d 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -399,7 +399,10 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( FunctionCallOutputContentItem::InputText { text } } DynamicToolCallOutputContentItem::InputImage { image_url } => { - FunctionCallOutputContentItem::InputImage { image_url } + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + } } }) .collect::>(); diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index 5d1c5fb12e..ab90fc438f 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -208,6 +208,7 @@ mod tests { "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10_000}, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272_000, "experimental_supported_tools": [], })) diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 2b61f0de60..3d9133700c 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -86,6 +86,7 @@ async fn models_client_hits_models_endpoint() { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c3a37bb989..eafedc9d73 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -48,6 +48,8 @@ codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-cache = { workspace = true } +codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } @@ -64,6 +66,7 @@ eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } iana-time-zone = { workspace = true } +image = { workspace = true, features = ["jpeg", "png", "webp"] } indexmap = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } @@ -88,7 +91,6 @@ sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } tempfile = { workspace = true } -test-case = "3.3.1" test-log = { workspace = true } thiserror = { workspace = true } time = { workspace = true, features = [ @@ -157,11 +159,11 @@ codex-test-macros = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } -image = { workspace = true, features = ["jpeg", "png"] } insta = { workspace = true } maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } +test-case = "3.3.1" opentelemetry_sdk = { workspace = true, features = [ "experimental_metrics_custom_reader", "metrics", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 74af6d50c6..749f6de23b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -355,6 +355,9 @@ "fast_mode": { "type": "boolean" }, + "image_detail_original": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -1739,6 +1742,9 @@ "fast_mode": { "type": "boolean" }, + "image_detail_original": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, diff --git a/codex-rs/core/models.json b/codex-rs/core/models.json index 2fd55b9869..04660bf46a 100644 --- a/codex-rs/core/models.json +++ b/codex-rs/core/models.json @@ -15,6 +15,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": true, + "supports_image_detail_original": true, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5.3-codex", @@ -83,6 +84,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": true, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5.2-codex", @@ -155,6 +157,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5.1-codex-max", @@ -220,6 +223,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5.1-codex", @@ -281,6 +285,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": true, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "none", "slug": "gpt-5.2", @@ -346,6 +351,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": true, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "none", "slug": "gpt-5.1", @@ -407,6 +413,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5-codex", @@ -468,6 +475,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "none", "slug": "gpt-5", @@ -532,6 +540,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 128000, "reasoning_summary_format": "none", "slug": "gpt-oss-120b", @@ -589,6 +598,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 128000, "reasoning_summary_format": "none", "slug": "gpt-oss-20b", @@ -647,6 +657,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5.1-codex-mini", @@ -704,6 +715,7 @@ "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "reasoning_summary_format": "experimental", "slug": "gpt-5-codex-mini", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9e5974b487..d81c10f396 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1305,6 +1305,7 @@ mod tests { "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10000}, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272000, "auto_compact_token_limit": null, "experimental_supported_tools": [] diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index e4b7755abf..40e0f31e41 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -2,21 +2,29 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::event_mapping::is_contextual_user_message_content; use crate::truncate::TruncationPolicy; +use crate::truncate::approx_bytes_for_tokens; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count_i64; use crate::truncate::truncate_function_output_items_with_policy; use crate::truncate::truncate_text; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; +use codex_utils_cache::BlockingLruCache; +use codex_utils_cache::sha1_digest; +use std::num::NonZeroUsize; use std::ops::Deref; +use std::sync::LazyLock; /// Transcript of thread history #[derive(Debug, Clone, Default)] @@ -428,7 +436,19 @@ fn estimate_item_token_count(item: &ResponseItem) -> i64 { /// /// The estimator later converts bytes to tokens using a 4-bytes/token heuristic /// with ceiling division, so 7,373 bytes maps to approximately 1,844 tokens. -const IMAGE_BYTES_ESTIMATE: i64 = 7373; +const RESIZED_IMAGE_BYTES_ESTIMATE: i64 = 7373; +// See https://platform.openai.com/docs/guides/images-vision#calculating-costs. +// Use a direct 32px patch count only for `detail: "original"`; +// all other image inputs continue to use `RESIZED_IMAGE_BYTES_ESTIMATE`. +const ORIGINAL_IMAGE_PATCH_SIZE: u32 = 32; +const ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE: usize = 32; + +static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock>> = + LazyLock::new(|| { + BlockingLruCache::new( + NonZeroUsize::new(ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE).unwrap_or(NonZeroUsize::MIN), + ) + }); pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> i64 { match item { @@ -444,15 +464,15 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> let raw = serde_json::to_string(item) .map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX)) .unwrap_or_default(); - let (payload_bytes, image_count) = image_data_url_estimate_adjustment(item); - if payload_bytes == 0 || image_count == 0 { + let (payload_bytes, replacement_bytes) = image_data_url_estimate_adjustment(item); + if payload_bytes == 0 || replacement_bytes == 0 { raw } else { - // Replace raw base64 payload bytes with a fixed per-image cost. - // We intentionally preserve the data URL prefix and JSON wrapper - // bytes already included in `raw`. + // Replace raw base64 payload bytes with a per-image estimate. + // We intentionally preserve the data URL prefix and JSON + // wrapper bytes already included in `raw`. raw.saturating_sub(payload_bytes) - .saturating_add(image_count.saturating_mul(IMAGE_BYTES_ESTIMATE)) + .saturating_add(replacement_bytes) } } } @@ -463,7 +483,7 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> /// /// We only discount payloads for `data:image/...;base64,...` URLs (case /// insensitive markers) and leave everything else at raw serialized size. -fn base64_data_url_payload_len(url: &str) -> Option { +fn parse_base64_image_data_url(url: &str) -> Option<&str> { if !url .get(.."data:".len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")) @@ -489,22 +509,62 @@ fn base64_data_url_payload_len(url: &str) -> Option { if !has_base64_marker { return None; } - Some(payload.len()) + Some(payload) +} + +fn estimate_original_image_bytes(image_url: &str) -> Option { + let key = sha1_digest(image_url.as_bytes()); + ORIGINAL_IMAGE_ESTIMATE_CACHE.get_or_insert_with(key, || { + let payload = match parse_base64_image_data_url(image_url) { + Some(payload) => payload, + None => { + tracing::trace!("skipping original-detail estimate for non-base64 image data URL"); + return None; + } + }; + let bytes = match BASE64_STANDARD.decode(payload) { + Ok(bytes) => bytes, + Err(error) => { + tracing::trace!("failed to decode original-detail image payload: {error}"); + return None; + } + }; + let dynamic = match image::load_from_memory(&bytes) { + Ok(dynamic) => dynamic, + Err(error) => { + tracing::trace!("failed to decode original-detail image bytes: {error}"); + return None; + } + }; + let width = i64::from(dynamic.width()); + let height = i64::from(dynamic.height()); + let patch_size = i64::from(ORIGINAL_IMAGE_PATCH_SIZE); + let patches_wide = width.saturating_add(patch_size.saturating_sub(1)) / patch_size; + let patches_high = height.saturating_add(patch_size.saturating_sub(1)) / patch_size; + let patch_count = patches_wide.saturating_mul(patches_high); + let patch_count = usize::try_from(patch_count).unwrap_or(usize::MAX); + Some(i64::try_from(approx_bytes_for_tokens(patch_count)).unwrap_or(i64::MAX)) + }) } /// Scans one response item for discount-eligible inline image data URLs and /// returns: /// - total base64 payload bytes to subtract from raw serialized size -/// - count of qualifying images to replace with `IMAGE_BYTES_ESTIMATE` +/// - total replacement byte estimate for those images fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { let mut payload_bytes = 0i64; - let mut image_count = 0i64; + let mut replacement_bytes = 0i64; - let mut accumulate = |image_url: &str| { - if let Some(payload_len) = base64_data_url_payload_len(image_url) { + let mut accumulate = |image_url: &str, detail: Option| { + if let Some(payload_len) = parse_base64_image_data_url(image_url).map(str::len) { payload_bytes = payload_bytes.saturating_add(i64::try_from(payload_len).unwrap_or(i64::MAX)); - image_count = image_count.saturating_add(1); + replacement_bytes = replacement_bytes.saturating_add(match detail { + Some(ImageDetail::Original) => { + estimate_original_image_bytes(image_url).unwrap_or(RESIZED_IMAGE_BYTES_ESTIMATE) + } + _ => RESIZED_IMAGE_BYTES_ESTIMATE, + }); } }; @@ -512,7 +572,7 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { ResponseItem::Message { content, .. } => { for content_item in content { if let ContentItem::InputImage { image_url } = content_item { - accumulate(image_url); + accumulate(image_url, None); } } } @@ -520,8 +580,10 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { | ResponseItem::CustomToolCallOutput { output, .. } => { if let FunctionCallOutputBody::ContentItems(items) = &output.body { for content_item in items { - if let FunctionCallOutputContentItem::InputImage { image_url } = content_item { - accumulate(image_url); + if let FunctionCallOutputContentItem::InputImage { image_url, detail } = + content_item + { + accumulate(image_url, *detail); } } } @@ -529,7 +591,7 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) { _ => {} } - (payload_bytes, image_count) + (payload_bytes, replacement_bytes) } fn is_model_generated_item(item: &ResponseItem) -> bool { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 798abc7675..46aa462322 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,12 +1,15 @@ use super::*; use crate::truncate; use crate::truncate::TruncationPolicy; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_git::GhostCommit; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; @@ -14,6 +17,9 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use image::ImageBuffer; +use image::ImageFormat; +use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -276,6 +282,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/result.png".to_string(), + detail: None, }, ]), }, @@ -294,6 +301,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/js-repl-result.png".to_string(), + detail: None, }, ]), }, @@ -489,6 +497,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { body: FunctionCallOutputBody::ContentItems(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, ]), success: Some(true), @@ -1302,7 +1311,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { let raw_len = serde_json::to_string(&image_item).unwrap().len() as i64; let estimated = estimate_response_item_model_visible_bytes(&image_item); - let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + let expected = raw_len - payload.len() as i64 + RESIZED_IMAGE_BYTES_ESTIMATE; let text_only_estimated = estimate_response_item_model_visible_bytes(&text_only_item); assert_eq!(estimated, expected); @@ -1320,13 +1329,16 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { FunctionCallOutputContentItem::InputText { text: "Screenshot captured".to_string(), }, - FunctionCallOutputContentItem::InputImage { image_url }, + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + }, ]), }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; let estimated = estimate_response_item_model_visible_bytes(&item); - let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + let expected = raw_len - payload.len() as i64 + RESIZED_IMAGE_BYTES_ESTIMATE; assert_eq!(estimated, expected); assert!(estimated < raw_len); @@ -1342,13 +1354,16 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { FunctionCallOutputContentItem::InputText { text: "Screenshot captured".to_string(), }, - FunctionCallOutputContentItem::InputImage { image_url }, + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + }, ]), }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; let estimated = estimate_response_item_model_visible_bytes(&item); - let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + let expected = raw_len - payload.len() as i64 + RESIZED_IMAGE_BYTES_ESTIMATE; assert_eq!(estimated, expected); assert!(estimated < raw_len); @@ -1370,6 +1385,7 @@ fn non_base64_image_urls_are_unchanged() { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "file:///tmp/foo.png".to_string(), + detail: None, }, ]), }; @@ -1409,7 +1425,10 @@ fn non_image_base64_data_url_is_unchanged() { let item = ResponseItem::FunctionCallOutput { call_id: "call-octet".to_string(), output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { image_url }, + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + }, ]), }; @@ -1433,7 +1452,7 @@ fn mixed_case_data_url_markers_are_adjusted() { let raw_len = serde_json::to_string(&item).unwrap().len() as i64; let estimated = estimate_response_item_model_visible_bytes(&item); - let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE; + let expected = raw_len - payload.len() as i64 + RESIZED_IMAGE_BYTES_ESTIMATE; assert_eq!(estimated, expected); } @@ -1465,7 +1484,70 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { let raw_len = serde_json::to_string(&item).unwrap().len() as i64; let payload_sum = (payload_one.len() + payload_two.len()) as i64; let estimated = estimate_response_item_model_visible_bytes(&item); - let expected = raw_len - payload_sum + (2 * IMAGE_BYTES_ESTIMATE); + let expected = raw_len - payload_sum + (2 * RESIZED_IMAGE_BYTES_ESTIMATE); + + assert_eq!(estimated, expected); +} + +#[test] +fn original_detail_images_scale_with_dimensions() { + // 2304x864 at 32px patches yields 72 * 27 = 1,944 patches. + // The byte heuristic uses 4 bytes per token, so the replacement cost is 7,776 bytes. + const EXPECTED_ORIGINAL_DETAIL_IMAGE_BYTES: i64 = 7_776; + + let width = 2304; + let height = 864; + let image = ImageBuffer::from_pixel(width, height, Rgba([12u8, 34, 56, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut bytes, ImageFormat::Png) + .expect("encode png"); + let payload = BASE64_STANDARD.encode(bytes.get_ref()); + let image_url = format!("data:image/png;base64,{payload}"); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-original".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url, + detail: Some(ImageDetail::Original), + }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - payload.len() as i64 + EXPECTED_ORIGINAL_DETAIL_IMAGE_BYTES; + + assert_eq!(estimated, expected); +} + +#[test] +fn original_detail_webp_images_scale_with_dimensions() { + // Same dimensions as the PNG case above, so the patch-based replacement cost is the same. + const EXPECTED_ORIGINAL_DETAIL_IMAGE_BYTES: i64 = 7_776; + + let width = 2304; + let height = 864; + let image = ImageBuffer::from_pixel(width, height, Rgba([12u8, 34, 56, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut bytes, ImageFormat::WebP) + .expect("encode webp"); + let payload = BASE64_STANDARD.encode(bytes.get_ref()); + let image_url = format!("data:image/webp;base64,{payload}"); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-original-webp".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url, + detail: Some(ImageDetail::Original), + }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let expected = raw_len - payload.len() as i64 + EXPECTED_ORIGINAL_DETAIL_IMAGE_BYTES; assert_eq!(estimated, expected); } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d76c2dfc42..30c38e835f 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -119,6 +119,8 @@ pub enum Feature { MemoryTool, /// Append additional AGENTS.md guidance to user instructions. ChildAgentsMd, + /// Allow `detail: "original"` image outputs on supported models. + ImageDetailOriginal, /// Enforce UTF8 output in Powershell. PowershellUtf8, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. @@ -529,6 +531,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ImageDetailOriginal, + key: "image_detail_original", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 1d7309020e..35e723d253 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -471,6 +471,7 @@ mod tests { "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10_000}, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": 272_000, "experimental_supported_tools": [], })) @@ -549,6 +550,8 @@ mod tests { .build() .await .expect("load default test config"); + let mut overlay = remote_model("gpt-overlay", "Overlay", 0); + overlay.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -556,7 +559,7 @@ mod tests { codex_home.path().to_path_buf(), auth_manager, Some(ModelsResponse { - models: vec![remote_model("gpt-overlay", "Overlay", 0)], + models: vec![overlay], }), CollaborationModesConfig::default(), ); @@ -568,6 +571,7 @@ mod tests { assert_eq!(model_info.slug, "gpt-overlay-experiment"); assert_eq!(model_info.display_name, "Overlay"); assert_eq!(model_info.context_window, Some(272_000)); + assert!(model_info.supports_image_detail_original); assert!(!model_info.supports_parallel_tool_calls); assert!(!model_info.used_fallback_model_metadata); } @@ -580,26 +584,24 @@ mod tests { .build() .await .expect("load default test config"); + let mut remote = remote_model("gpt-image", "Image", 0); + remote.supports_image_detail_original = true; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let manager = ModelsManager::new( codex_home.path().to_path_buf(), auth_manager, - None, + Some(ModelsResponse { + models: vec![remote], + }), CollaborationModesConfig::default(), ); - let known_slug = manager - .get_remote_models() - .await - .first() - .expect("bundled models should include at least one model") - .slug - .clone(); - let namespaced_model = format!("custom/{known_slug}"); + let namespaced_model = "custom/gpt-image".to_string(); let model_info = manager.get_model_info(&namespaced_model, &config).await; assert_eq!(model_info.slug, namespaced_model); + assert!(model_info.supports_image_detail_original); assert!(!model_info.used_fallback_model_metadata); } diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 4824e4cd11..643d42571d 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -80,6 +80,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 8323b86a1b..6370b15447 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -55,6 +55,9 @@ fn render_js_repl_instructions(config: &Config) -> Option { section.push_str("- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n"); section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n"); section.push_str("- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n"); + if config.features.enabled(Feature::ImageDetailOriginal) { + section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n"); + } section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n"); section.push_str("- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n"); @@ -492,6 +495,21 @@ mod tests { assert_eq!(res, expected); } + #[tokio::test] + async fn js_repl_original_resolution_guidance_is_feature_gated() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, 4096, None).await; + cfg.features + .enable(Feature::JsRepl) + .enable(Feature::ImageDetailOriginal); + + let res = get_user_instructions(&cfg, None) + .await + .expect("js_repl instructions expected"); + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + assert_eq!(res, expected); + } + /// When both system instructions *and* a project doc are present the two /// should be concatenated with the separator. #[tokio::test] diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 0700b4d013..9f85e6acf8 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -222,6 +222,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), @@ -239,6 +240,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 9bf03acb00..6401751125 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,10 +1,15 @@ use async_trait::async_trait; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::ImageDetail; +use codex_protocol::models::local_image_content_items_with_label_number; use codex_protocol::openai_models::InputModality; +use codex_utils_image::PromptImageMode; use serde::Deserialize; use tokio::fs; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::protocol::EventMsg; use crate::protocol::ViewImageToolCallEvent; @@ -14,8 +19,6 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use codex_protocol::models::ContentItem; -use codex_protocol::models::local_image_content_items_with_label_number; pub struct ViewImageHandler; @@ -81,15 +84,26 @@ impl ToolHandler for ViewImageHandler { } let event_path = abs_path.clone(); - let content = local_image_content_items_with_label_number(&abs_path, None); - let content = content + let use_original_detail = turn.config.features.enabled(Feature::ImageDetailOriginal) + && turn.model_info.supports_image_detail_original; + let image_mode = if use_original_detail { + PromptImageMode::Original + } else { + PromptImageMode::ResizeToFit + }; + let image_detail = use_original_detail.then_some(ImageDetail::Original); + + let content = local_image_content_items_with_label_number(&abs_path, None, image_mode) .into_iter() .map(|item| match item { ContentItem::InputText { text } => { FunctionCallOutputContentItem::InputText { text } } ContentItem::InputImage { image_url } => { - FunctionCallOutputContentItem::InputImage { image_url } + FunctionCallOutputContentItem::InputImage { + image_url, + detail: image_detail, + } } ContentItem::OutputText { text } => { FunctionCallOutputContentItem::InputText { text } diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index a234fd0eb8..ea9f42ce63 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1900,6 +1900,7 @@ mod tests { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,abcd".to_string(), + detail: None, }, ]), }; @@ -1929,6 +1930,7 @@ mod tests { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,abcd".to_string(), + detail: None, }, ]), }; @@ -2417,15 +2419,17 @@ console.log(out.output?.body?.text ?? ""); image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(), + detail: None, }] .as_slice() ); - let [FunctionCallOutputContentItem::InputImage { image_url }] = + let [FunctionCallOutputContentItem::InputImage { image_url, detail }] = result.content_items.as_slice() else { panic!("view_image should return exactly one input_image content item"); }; assert!(image_url.starts_with("data:image/png;base64,")); + assert_eq!(*detail, None); assert!(session.get_pending_input().await.is_empty()); Ok(()) @@ -2515,6 +2519,7 @@ console.log(out.type); }, FunctionCallOutputContentItem::InputImage { image_url: image_url.to_string(), + detail: None, }, ] ); diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 309ed91b6f..fb275e6d46 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -138,9 +138,10 @@ pub(crate) fn truncate_function_output_items_with_policy( remaining_budget = 0; } } - FunctionCallOutputContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { image_url, detail } => { out.push(FunctionCallOutputContentItem::InputImage { image_url: image_url.clone(), + detail: *detail, }); } } @@ -491,6 +492,7 @@ mod tests { FunctionCallOutputContentItem::InputText { text: t2.clone() }, FunctionCallOutputContentItem::InputImage { image_url: "img:mid".to_string(), + detail: None, }, FunctionCallOutputContentItem::InputText { text: t3 }, FunctionCallOutputContentItem::InputText { text: t4 }, @@ -518,7 +520,8 @@ mod tests { assert_eq!( output[2], FunctionCallOutputContentItem::InputImage { - image_url: "img:mid".to_string() + image_url: "img:mid".to_string(), + detail: None, } ); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 385072f912..e8fbcc851c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -278,6 +278,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, @@ -439,6 +440,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(large_context_window), auto_compact_token_limit: None, effective_context_window_percent, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index d2202b58cf..54b0629243 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -344,6 +344,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 73f57ba0b0..f3a1f2e4db 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -619,6 +619,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(128_000), auto_compact_token_limit: None, effective_context_window_percent: 95, @@ -729,6 +730,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(128_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 63f15f9c0d..ac2bc71565 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -303,6 +303,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, @@ -543,6 +544,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, @@ -1007,6 +1009,7 @@ fn test_remote_model_with_policy( apply_patch_tool_type: None, truncation_policy, supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 06c0042985..a8d414e998 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -409,6 +409,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5b8b9e0888..9b73afbc1b 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -291,6 +291,208 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_can_preserve_original_resolution_on_gpt5_3_codex() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("gpt-5.3-codex") + .with_config(|config| { + config.features.enable(Feature::ImageDetailOriginal); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let rel_path = "assets/original-example.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-original"; + let arguments = serde_json::json!({ "path": rel_path }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please add the original screenshot".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event_with_timeout( + &codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + Duration::from_secs(10), + ) + .await; + + let req = mock.single_request(); + let function_output = req.function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("function_call_output should be a content item array"); + assert_eq!(output_items.len(), 1); + assert_eq!( + output_items[0].get("detail").and_then(Value::as_str), + Some("original") + ); + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) + .expect("image_url present"); + + let (_, encoded) = image_url + .split_once(',') + .expect("image url contains data prefix"); + let decoded = BASE64_STANDARD + .decode(encoded) + .expect("image data decodes from base64 for request"); + let preserved = load_from_memory(&decoded).expect("load preserved image"); + let (width, height) = preserved.dimensions(); + assert_eq!(width, original_width); + assert_eq!(height, original_height); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn view_image_tool_keeps_legacy_behavior_below_gpt5_3_codex() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { + config.features.enable(Feature::ImageDetailOriginal); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let rel_path = "assets/original-example-lower-model.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let original_width = 2304; + let original_height = 864; + let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([0u8, 80, 255, 255])); + image.save(&abs_path)?; + + let call_id = "view-image-original-lower-model"; + let arguments = serde_json::json!({ "path": rel_path }).to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "view_image", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please add the screenshot".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event_with_timeout( + &codex, + |event| matches!(event, EventMsg::TurnComplete(_)), + Duration::from_secs(10), + ) + .await; + + let req = mock.single_request(); + let function_output = req.function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("function_call_output should be a content item array"); + assert_eq!(output_items.len(), 1); + assert_eq!(output_items[0].get("detail"), None); + + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) + .expect("image_url present"); + + let (prefix, encoded) = image_url + .split_once(',') + .expect("image url contains data prefix"); + assert_eq!(prefix, "data:image/png;base64"); + + let decoded = BASE64_STANDARD + .decode(encoded) + .expect("image data decodes from base64 for request"); + let resized = load_from_memory(&decoded).expect("load resized image"); + let (resized_width, resized_height) = resized.dimensions(); + assert!(resized_width <= 2048); + assert!(resized_height <= 768); + assert!(resized_width < original_width); + assert!(resized_height < original_height); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_view_image_tool_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -674,6 +876,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: Some(272_000), auto_compact_token_limit: None, effective_context_window_percent: 95, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8461209823..8be7c9abf3 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use std::path::Path; -use codex_utils_image::load_and_resize_to_fit; +use codex_utils_image::PromptImageMode; +use codex_utils_image::load_for_prompt; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; @@ -175,6 +176,15 @@ pub enum ContentItem { OutputText { text: String }, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum ImageDetail { + Auto, + Low, + High, + Original, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] /// Classifies an assistant message as interim commentary or final answer text. @@ -710,8 +720,9 @@ fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> Co pub fn local_image_content_items_with_label_number( path: &std::path::Path, label_number: Option, + mode: PromptImageMode, ) -> Vec { - match load_and_resize_to_fit(path) { + match load_for_prompt(path, mode) { Ok(image) => { let mut items = Vec::with_capacity(3); if let Some(label_number) = label_number { @@ -872,7 +883,11 @@ impl From> for ResponseInputItem { } UserInput::LocalImage { path } => { image_index += 1; - local_image_content_items_with_label_number(&path, Some(image_index)) + local_image_content_items_with_label_number( + &path, + Some(image_index), + PromptImageMode::ResizeToFit, + ) } UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core }) @@ -937,9 +952,16 @@ pub struct ShellCommandToolCallParams { #[serde(tag = "type", rename_all = "snake_case")] pub enum FunctionCallOutputContentItem { // Do not rename, these are serialized and used directly in the responses API. - InputText { text: String }, + InputText { + text: String, + }, // Do not rename, these are serialized and used directly in the responses API. - InputImage { image_url: String }, + InputImage { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, } /// Converts structured function-call output content into plain text for @@ -983,7 +1005,10 @@ impl From Self::InputText { text } } crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } + Self::InputImage { + image_url, + detail: None, + } } } } @@ -1185,7 +1210,10 @@ fn convert_mcp_content_to_items( let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into()); format!("data:{mime_type};base64,{data}") }; - FunctionCallOutputContentItem::InputImage { image_url } + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + } } Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText { text: serde_json::to_string(content).unwrap_or_else(|_| "".to_string()), @@ -1239,6 +1267,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,Zm9v".to_string(), + detail: None, }] ); } @@ -1256,6 +1285,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,Zm9v".to_string(), + detail: None, }] ); } @@ -1278,6 +1308,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, FunctionCallOutputContentItem::InputText { text: "line 2".to_string(), @@ -1296,6 +1327,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, ]; @@ -1318,6 +1350,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), + detail: None, }, ]); @@ -1542,6 +1575,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), + detail: None, }, ] ); @@ -1567,6 +1601,7 @@ mod tests { output: FunctionCallOutputPayload::from_content_items(vec![ FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), + detail: None, }, ]), }; @@ -1602,6 +1637,7 @@ mod tests { items, vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BASE64".into(), + detail: None, }] ); @@ -1624,6 +1660,7 @@ mod tests { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,XYZ".into(), + detail: None, }, ]; assert_eq!( diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index b63f137442..089b002680 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -245,6 +245,8 @@ pub struct ModelInfo { pub apply_patch_tool_type: Option, pub truncation_policy: TruncationPolicyConfig, pub supports_parallel_tool_calls: bool, + #[serde(default)] + pub supports_image_detail_original: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window: Option, /// Token threshold for automatic compaction. When omitted, core derives it @@ -515,6 +517,7 @@ mod tests { apply_patch_tool_type: None, truncation_policy: TruncationPolicyConfig::bytes(10_000), supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: None, auto_compact_token_limit: None, effective_context_window_percent: 95, @@ -713,6 +716,7 @@ mod tests { .expect("deserialize model info"); assert_eq!(model.availability_nux, None); + assert!(!model.supports_image_detail_original); } #[test] diff --git a/codex-rs/utils/image/src/lib.rs b/codex-rs/utils/image/src/lib.rs index 0950bf176e..d0aba2f608 100644 --- a/codex-rs/utils/image/src/lib.rs +++ b/codex-rs/utils/image/src/lib.rs @@ -14,6 +14,7 @@ use image::ImageEncoder; use image::ImageFormat; use image::codecs::jpeg::JpegEncoder; use image::codecs::png::PngEncoder; +use image::codecs::webp::WebPEncoder; use image::imageops::FilterType; /// Maximum width used when resizing images before uploading. pub const MAX_WIDTH: u32 = 2048; @@ -33,24 +34,48 @@ pub struct EncodedImage { impl EncodedImage { pub fn into_data_url(self) -> String { let encoded = BASE64_STANDARD.encode(&self.bytes); - format!("data:{};base64,{}", self.mime, encoded) + format!("data:{};base64,{encoded}", self.mime) } } -static IMAGE_CACHE: LazyLock> = +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PromptImageMode { + ResizeToFit, + Original, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ImageCacheKey { + digest: [u8; 20], + mode: PromptImageMode, +} + +static IMAGE_CACHE: LazyLock> = LazyLock::new(|| BlockingLruCache::new(NonZeroUsize::new(32).unwrap_or(NonZeroUsize::MIN))); pub fn load_and_resize_to_fit(path: &Path) -> Result { + load_for_prompt(path, PromptImageMode::ResizeToFit) +} + +pub fn load_for_prompt( + path: &Path, + mode: PromptImageMode, +) -> Result { let path_buf = path.to_path_buf(); let file_bytes = read_file_bytes(path, &path_buf)?; - let key = sha1_digest(&file_bytes); + let key = ImageCacheKey { + digest: sha1_digest(&file_bytes), + mode, + }; IMAGE_CACHE.get_or_try_insert_with(key, move || { let format = match image::guess_format(&file_bytes) { Ok(ImageFormat::Png) => Some(ImageFormat::Png), Ok(ImageFormat::Jpeg) => Some(ImageFormat::Jpeg), + Ok(ImageFormat::Gif) => Some(ImageFormat::Gif), + Ok(ImageFormat::WebP) => Some(ImageFormat::WebP), _ => None, }; @@ -63,42 +88,54 @@ pub fn load_and_resize_to_fit(path: &Path) -> Result bool { + // Public API docs explicitly call out non-animated GIF support only. + // Preserve byte-for-byte only for formats we can safely pass through. + matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP + ) +} + fn read_file_bytes(path: &Path, path_for_error: &Path) -> Result, ImageProcessingError> { match tokio::runtime::Handle::try_current() { // If we're inside a Tokio runtime, avoid block_on (it panics on worker threads). @@ -123,6 +160,7 @@ fn encode_image( ) -> Result<(Vec, ImageFormat), ImageProcessingError> { let target_format = match preferred_format { ImageFormat::Jpeg => ImageFormat::Jpeg, + ImageFormat::WebP => ImageFormat::WebP, _ => ImageFormat::Png, }; @@ -153,6 +191,21 @@ fn encode_image( source, })?; } + ImageFormat::WebP => { + let rgba = image.to_rgba8(); + let encoder = WebPEncoder::new_lossless(&mut buffer); + encoder + .write_image( + rgba.as_raw(), + image.width(), + image.height(), + ColorType::Rgba8.into(), + ) + .map_err(|source| ImageProcessingError::Encode { + format: target_format, + source, + })?; + } _ => unreachable!("unsupported target_format should have been handled earlier"), } @@ -162,6 +215,8 @@ fn encode_image( fn format_to_mime(format: ImageFormat) -> String { match format { ImageFormat::Jpeg => "image/jpeg".to_string(), + ImageFormat::Gif => "image/gif".to_string(), + ImageFormat::WebP => "image/webp".to_string(), _ => "image/png".to_string(), } } @@ -176,38 +231,70 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn returns_original_image_when_within_bounds() { + for (format, mime) in [ + (ImageFormat::Png, "image/png"), + (ImageFormat::WebP, "image/webp"), + ] { + let temp_file = NamedTempFile::new().expect("temp file"); + let image = ImageBuffer::from_pixel(64, 32, Rgba([10u8, 20, 30, 255])); + image + .save_with_format(temp_file.path(), format) + .expect("write image to temp file"); + + let original_bytes = std::fs::read(temp_file.path()).expect("read written image"); + let encoded = load_and_resize_to_fit(temp_file.path()).expect("process image"); + + assert_eq!(encoded.width, 64); + assert_eq!(encoded.height, 32); + assert_eq!(encoded.mime, mime); + assert_eq!(encoded.bytes, original_bytes); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn downscales_large_image() { + for (format, mime) in [ + (ImageFormat::Png, "image/png"), + (ImageFormat::WebP, "image/webp"), + ] { + let temp_file = NamedTempFile::new().expect("temp file"); + let image = ImageBuffer::from_pixel(4096, 2048, Rgba([200u8, 10, 10, 255])); + image + .save_with_format(temp_file.path(), format) + .expect("write image to temp file"); + + let processed = load_and_resize_to_fit(temp_file.path()).expect("process image"); + + assert!(processed.width <= MAX_WIDTH); + assert!(processed.height <= MAX_HEIGHT); + assert_eq!(processed.mime, mime); + + let detected_format = + image::guess_format(&processed.bytes).expect("detect resized output format"); + assert_eq!(detected_format, format); + + let loaded = image::load_from_memory(&processed.bytes) + .expect("read resized bytes back into image"); + assert_eq!(loaded.dimensions(), (processed.width, processed.height)); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn preserves_large_image_in_original_mode() { let temp_file = NamedTempFile::new().expect("temp file"); - let image = ImageBuffer::from_pixel(64, 32, Rgba([10u8, 20, 30, 255])); + let image = ImageBuffer::from_pixel(4096, 2048, Rgba([180u8, 30, 30, 255])); image .save_with_format(temp_file.path(), ImageFormat::Png) .expect("write png to temp file"); let original_bytes = std::fs::read(temp_file.path()).expect("read written image"); + let processed = + load_for_prompt(temp_file.path(), PromptImageMode::Original).expect("process image"); - let encoded = load_and_resize_to_fit(temp_file.path()).expect("process image"); - - assert_eq!(encoded.width, 64); - assert_eq!(encoded.height, 32); - assert_eq!(encoded.mime, "image/png"); - assert_eq!(encoded.bytes, original_bytes); - } - - #[tokio::test(flavor = "multi_thread")] - async fn downscales_large_image() { - let temp_file = NamedTempFile::new().expect("temp file"); - let image = ImageBuffer::from_pixel(4096, 2048, Rgba([200u8, 10, 10, 255])); - image - .save_with_format(temp_file.path(), ImageFormat::Png) - .expect("write png to temp file"); - - let processed = load_and_resize_to_fit(temp_file.path()).expect("process image"); - - assert!(processed.width <= MAX_WIDTH); - assert!(processed.height <= MAX_HEIGHT); - - let loaded = - image::load_from_memory(&processed.bytes).expect("read resized bytes back into image"); - assert_eq!(loaded.dimensions(), (processed.width, processed.height)); + assert_eq!(processed.width, 4096); + assert_eq!(processed.height, 2048); + assert_eq!(processed.mime, "image/png"); + assert_eq!(processed.bytes, original_bytes); } #[tokio::test(flavor = "multi_thread")] From 1afbbc11c3e2452cd61a4b102eb96b60e45f2de0 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Tue, 3 Mar 2026 16:12:23 -0800 Subject: [PATCH 04/23] Ensure the env values of imported shell_environment_policy.set is string (#13402) --- codex-rs/core/src/external_agent_config.rs | 183 ++++++++++++++------- 1 file changed, 125 insertions(+), 58 deletions(-) diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 1d466e6f4b..d0fde766f5 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -126,7 +126,7 @@ impl ExternalAgentConfigService { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: format!( - "Migrate {} into {}.", + "Migrate {} into {}", source_settings.display(), target_config.display() ), @@ -153,7 +153,7 @@ impl ExternalAgentConfigService { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: format!( - "Copy skill folders from {} to {}.", + "Copy skill folders from {} to {}", source_skills.display(), target_skills.display() ), @@ -161,19 +161,23 @@ impl ExternalAgentConfigService { }); } - let source_agents_md = repo_root.map_or_else( - || self.claude_home.join("CLAUDE.md"), - |repo_root| repo_root.join("CLAUDE.md"), - ); + let source_agents_md = if let Some(repo_root) = repo_root { + find_repo_agents_md_source(repo_root)? + } else { + let path = self.claude_home.join("CLAUDE.md"); + is_non_empty_text_file(&path)?.then_some(path) + }; let target_agents_md = repo_root.map_or_else( || self.codex_home.join("AGENTS.md"), |repo_root| repo_root.join("AGENTS.md"), ); - if source_agents_md.is_file() && is_missing_or_empty_text_file(&target_agents_md)? { + if let Some(source_agents_md) = source_agents_md + && is_missing_or_empty_text_file(&target_agents_md)? + { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}.", + "Import {} to {}", source_agents_md.display(), target_agents_md.display() ), @@ -283,7 +287,10 @@ impl ExternalAgentConfigService { fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> { let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? { - (repo_root.join("CLAUDE.md"), repo_root.join("AGENTS.md")) + let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else { + return Ok(()); + }; + (source_agents_md, repo_root.join("AGENTS.md")) } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { return Ok(()); } else { @@ -292,7 +299,9 @@ impl ExternalAgentConfigService { self.codex_home.join("AGENTS.md"), ) }; - if !source_agents_md.is_file() || !is_missing_or_empty_text_file(&target_agents_md)? { + if !is_non_empty_text_file(&source_agents_md)? + || !is_missing_or_empty_text_file(&target_agents_md)? + { return Ok(()); } @@ -376,6 +385,27 @@ fn is_missing_or_empty_text_file(path: &Path) -> io::Result { Ok(fs::read_to_string(path)?.trim().is_empty()) } +fn is_non_empty_text_file(path: &Path) -> io::Result { + if !path.is_file() { + return Ok(false); + } + + Ok(!fs::read_to_string(path)?.trim().is_empty()) +} + +fn find_repo_agents_md_source(repo_root: &Path) -> io::Result> { + for candidate in [ + repo_root.join("CLAUDE.md"), + repo_root.join(".claude").join("CLAUDE.md"), + ] { + if is_non_empty_text_file(&candidate)? { + return Ok(Some(candidate)); + } + } + + Ok(None) +} + fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { fs::create_dir_all(target)?; @@ -487,7 +517,7 @@ fn build_config_from_external(settings: &JsonValue) -> io::Result { shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string())); shell_policy.insert( "set".to_string(), - TomlValue::Table(json_object_to_toml_table(env)?), + TomlValue::Table(json_object_to_env_toml_table(env)), ); root.insert( "shell_environment_policy".to_string(), @@ -511,36 +541,25 @@ fn build_config_from_external(settings: &JsonValue) -> io::Result { Ok(TomlValue::Table(root)) } -fn json_object_to_toml_table( +fn json_object_to_env_toml_table( object: &serde_json::Map, -) -> io::Result> { +) -> toml::map::Map { let mut table = toml::map::Map::new(); for (key, value) in object { - table.insert(key.clone(), json_to_toml_value(value)?); + if let Some(value) = json_env_value_to_string(value) { + table.insert(key.clone(), TomlValue::String(value)); + } } - Ok(table) + table } -fn json_to_toml_value(value: &JsonValue) -> io::Result { +fn json_env_value_to_string(value: &JsonValue) -> Option { match value { - JsonValue::Null => Ok(TomlValue::String("null".to_string())), - JsonValue::Bool(v) => Ok(TomlValue::Boolean(*v)), - JsonValue::Number(n) => { - if let Some(i) = n.as_i64() { - return Ok(TomlValue::Integer(i)); - } - if let Some(f) = n.as_f64() { - return Ok(TomlValue::Float(f)); - } - Err(invalid_data_error("unsupported JSON number")) - } - JsonValue::String(v) => Ok(TomlValue::String(v.clone())), - JsonValue::Array(values) => values - .iter() - .map(json_to_toml_value) - .collect::>>() - .map(TomlValue::Array), - JsonValue::Object(map) => json_object_to_toml_table(map).map(TomlValue::Table), + JsonValue::String(value) => Some(value.clone()), + JsonValue::Null => None, + JsonValue::Bool(value) => Some(value.to_string()), + JsonValue::Number(value) => Some(value.to_string()), + JsonValue::Array(_) | JsonValue::Object(_) => None, } } @@ -576,7 +595,7 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> { let serialized = toml::to_string_pretty(value) .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?; - fs::write(path, format!("{serialized}\n")) + fs::write(path, format!("{}\n", serialized.trim_end())) } fn is_empty_toml_table(value: &TomlValue) -> bool { @@ -638,7 +657,7 @@ mod tests { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: format!( - "Migrate {} into {}.", + "Migrate {} into {}", claude_home.join("settings.json").display(), codex_home.join("config.toml").display() ), @@ -647,7 +666,7 @@ mod tests { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: format!( - "Copy skill folders from {} to {}.", + "Copy skill folders from {} to {}", claude_home.join("skills").display(), agents_skills.display() ), @@ -656,7 +675,7 @@ mod tests { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}.", + "Import {} to {}", claude_home.join("CLAUDE.md").display(), codex_home.join("AGENTS.md").display() ), @@ -687,7 +706,7 @@ mod tests { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}.", + "Import {} to {}", repo_root.join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -696,7 +715,7 @@ mod tests { ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: format!( - "Import {} to {}.", + "Import {} to {}", repo_root.join("CLAUDE.md").display(), repo_root.join("AGENTS.md").display(), ), @@ -717,7 +736,7 @@ mod tests { fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills"); fs::write( claude_home.join("settings.json"), - r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar"},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#, + r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{"x":1}},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#, ) .expect("write settings"); fs::write( @@ -752,23 +771,10 @@ mod tests { "Codex guidance" ); - let parsed_config: TomlValue = toml::from_str( - &fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - ) - .expect("parse config"); - let expected_config: TomlValue = toml::from_str( - r#" - sandbox_mode = "workspace-write" - - [shell_environment_policy] - inherit = "core" - - [shell_environment_policy.set] - FOO = "bar" - "#, - ) - .expect("parse expected"); - assert_eq!(parsed_config, expected_config); + assert_eq!( + fs::read_to_string(codex_home.join("config.toml")).expect("read config"), + "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" + ); assert_eq!( fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) .expect("read copied skill"), @@ -917,4 +923,65 @@ mod tests { "Codex guidance" ); } + + #[test] + fn detect_repo_prefers_non_empty_dot_claude_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); + fs::write(repo_root.join("CLAUDE.md"), " \n\t").expect("write empty root source"); + fs::write( + repo_root.join(".claude").join("CLAUDE.md"), + "Claude code guidance", + ) + .expect("write dot claude source"); + + let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: format!( + "Import {} to {}", + repo_root.join(".claude").join("CLAUDE.md").display(), + repo_root.join("AGENTS.md").display(), + ), + cwd: Some(repo_root), + }] + ); + } + + #[test] + fn import_repo_uses_non_empty_dot_claude_agents_source() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git"); + fs::create_dir_all(repo_root.join(".claude")).expect("create dot claude"); + fs::write(repo_root.join("CLAUDE.md"), "").expect("write empty root source"); + fs::write( + repo_root.join(".claude").join("CLAUDE.md"), + "Claude code guidance", + ) + .expect("write dot claude source"); + + service_for_paths(root.path().join(".claude"), root.path().join(".codex")) + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: Some(repo_root.clone()), + }]) + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), + "Codex guidance" + ); + } } From c4cb594e73fda4b9247d606fb00eb2c58da26647 Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Tue, 3 Mar 2026 16:25:59 -0800 Subject: [PATCH 05/23] Make js_repl image output controllable (#13331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Instead of always adding inner function call outputs to the model context, let js code decide which ones to return. - Stop auto-hoisting nested tool outputs from `codex.tool(...)` into the outer `js_repl` function output. - Keep `codex.tool(...)` return values unchanged as structured JS objects. - Add `codex.emitImage(...)` as the explicit path for attaching an image to the outer `js_repl` function output. - Support emitting from a direct image URL, a single `input_image` item, an explicit `{ bytes, mimeType }` object, or a raw tool response object containing exactly one image. - Preserve existing `view_image` original-resolution behavior when JS emits the raw `view_image` tool result. - Suppress the special `ViewImageToolCall` event for `js_repl`-sourced `view_image` calls so nested inspection stays side-effect free until JS explicitly emits. - Update the `js_repl` docs and generated project instructions with both recommended patterns: - `await codex.emitImage(codex.tool("view_image", { path }))` - `await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg" })` #### [git stack](https://github.com/magus/git-stack-cli) - ✅ `1` https://github.com/openai/codex/pull/13050 - 👉 `2` https://github.com/openai/codex/pull/13331 - ⏳ `3` https://github.com/openai/codex/pull/13049 --- codex-rs/core/src/project_doc.rs | 18 +- codex-rs/core/src/tools/js_repl/kernel.js | 317 ++++++++++- codex-rs/core/src/tools/js_repl/mod.rs | 614 +++++++++++++++++++--- codex-rs/core/tests/suite/client.rs | 123 +++++ codex-rs/core/tests/suite/view_image.rs | 122 ++++- docs/js_repl.md | 7 +- 6 files changed, 1124 insertions(+), 77 deletions(-) diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 6370b15447..debe39c2ad 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -52,9 +52,13 @@ fn render_js_repl_instructions(config: &Config) -> Option { "- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n", ); section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); - section.push_str("- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n"); - section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n"); - section.push_str("- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n"); + section.push_str( + "- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n", + ); + section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n"); + section.push_str("- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n"); + section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n"); + section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n"); if config.features.enabled(Feature::ImageDetailOriginal) { section.push_str("- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n"); } @@ -67,7 +71,7 @@ fn render_js_repl_instructions(config: &Config) -> Option { .push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n"); } - section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."); + section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."); Some(section) } @@ -476,7 +480,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; assert_eq!(res, expected); } @@ -491,7 +495,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; assert_eq!(res, expected); } @@ -506,7 +510,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; assert_eq!(res, expected); } diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index f5459fde79..038783fb2b 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -115,7 +115,10 @@ function isDeniedBuiltin(specifier) { /** @type {Map void>} */ const pendingTool = new Map(); +/** @type {Map void>} */ +const pendingEmitImage = new Map(); let toolCounter = 0; +let emitImageCounter = 0; const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd(); const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; const moduleSearchBases = (() => { @@ -480,8 +483,255 @@ function withCapturedConsole(ctx, fn) { }); } +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toByteArray(value) { + if (value instanceof Uint8Array) { + return value; + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + return null; +} + +function encodeByteImage(bytes, mimeType, detail) { + if (bytes.byteLength === 0) { + throw new Error("codex.emitImage expected non-empty bytes"); + } + if (typeof mimeType !== "string" || !mimeType) { + throw new Error("codex.emitImage expected a non-empty mimeType"); + } + const image_url = `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`; + return { image_url, detail }; +} + +function parseImageDetail(detail) { + if (typeof detail === "undefined") { + return undefined; + } + if (typeof detail !== "string" || !detail) { + throw new Error("codex.emitImage expected detail to be a non-empty string"); + } + if ( + detail !== "auto" && + detail !== "low" && + detail !== "high" && + detail !== "original" + ) { + throw new Error( + 'codex.emitImage expected detail to be one of "auto", "low", "high", or "original"', + ); + } + return detail; +} + +function parseInputImageItem(value) { + if (!isPlainObject(value) || value.type !== "input_image") { + return null; + } + if (typeof value.image_url !== "string" || !value.image_url) { + throw new Error("codex.emitImage expected a non-empty image_url"); + } + return { + images: [{ image_url: value.image_url, detail: parseImageDetail(value.detail) }], + textCount: 0, + }; +} + +function parseContentItems(items) { + if (!Array.isArray(items)) { + return null; + } + + const images = []; + let textCount = 0; + for (const item of items) { + if (!isPlainObject(item) || typeof item.type !== "string") { + throw new Error("codex.emitImage received malformed content items"); + } + if (item.type === "input_image") { + if (typeof item.image_url !== "string" || !item.image_url) { + throw new Error("codex.emitImage expected a non-empty image_url"); + } + images.push({ + image_url: item.image_url, + detail: parseImageDetail(item.detail), + }); + continue; + } + if (item.type === "input_text" || item.type === "output_text") { + textCount += 1; + continue; + } + throw new Error( + `codex.emitImage does not support content item type "${item.type}"`, + ); + } + + return { images, textCount }; +} + +function parseByteImageValue(value) { + if (!isPlainObject(value) || !("bytes" in value)) { + return null; + } + const bytes = toByteArray(value.bytes); + if (!bytes) { + throw new Error( + "codex.emitImage expected bytes to be Buffer, Uint8Array, ArrayBuffer, or ArrayBufferView", + ); + } + const detail = parseImageDetail(value.detail); + return encodeByteImage(bytes, value.mimeType, detail); +} + +function parseToolOutput(output) { + if (typeof output === "string") { + return { + images: [], + textCount: output.length > 0 ? 1 : 0, + }; + } + + const parsedItems = parseContentItems(output); + if (parsedItems) { + return parsedItems; + } + + throw new Error("codex.emitImage received an unsupported tool output shape"); +} + +function normalizeMcpImageData(data, mimeType) { + if (typeof data !== "string" || !data) { + throw new Error("codex.emitImage expected MCP image data"); + } + if (data.startsWith("data:")) { + return data; + } + const normalizedMimeType = + typeof mimeType === "string" && mimeType ? mimeType : "application/octet-stream"; + return `data:${normalizedMimeType};base64,${data}`; +} + +function parseMcpToolResult(result) { + if (typeof result === "string") { + return { images: [], textCount: result.length > 0 ? 1 : 0 }; + } + + if (!isPlainObject(result)) { + throw new Error("codex.emitImage received an unsupported MCP result"); + } + + if ("Err" in result) { + const error = result.Err; + return { images: [], textCount: typeof error === "string" && error ? 1 : 0 }; + } + + if (!("Ok" in result)) { + throw new Error("codex.emitImage received an unsupported MCP result"); + } + + const ok = result.Ok; + if (!isPlainObject(ok) || !Array.isArray(ok.content)) { + throw new Error("codex.emitImage received malformed MCP content"); + } + + const images = []; + let textCount = 0; + for (const item of ok.content) { + if (!isPlainObject(item) || typeof item.type !== "string") { + throw new Error("codex.emitImage received malformed MCP content"); + } + if (item.type === "image") { + images.push({ + image_url: normalizeMcpImageData(item.data, item.mimeType ?? item.mime_type), + }); + continue; + } + if (item.type === "text") { + textCount += 1; + continue; + } + throw new Error( + `codex.emitImage does not support MCP content type "${item.type}"`, + ); + } + + return { images, textCount }; +} + +function requireSingleImage(parsed) { + if (parsed.textCount > 0) { + throw new Error("codex.emitImage does not accept mixed text and image content"); + } + if (parsed.images.length !== 1) { + throw new Error("codex.emitImage expected exactly one image"); + } + return parsed.images[0]; +} + +function normalizeEmitImageValue(value) { + if (typeof value === "string") { + if (!value) { + throw new Error("codex.emitImage expected a non-empty image URL"); + } + return { image_url: value }; + } + + const directItem = parseInputImageItem(value); + if (directItem) { + return requireSingleImage(directItem); + } + + const byteImage = parseByteImageValue(value); + if (byteImage) { + return byteImage; + } + + const directItems = parseContentItems(value); + if (directItems) { + return requireSingleImage(directItems); + } + + if (!isPlainObject(value)) { + throw new Error("codex.emitImage received an unsupported value"); + } + + if (value.type === "message") { + return requireSingleImage(parseContentItems(value.content)); + } + + if ( + value.type === "function_call_output" || + value.type === "custom_tool_call_output" + ) { + return requireSingleImage(parseToolOutput(value.output)); + } + + if (value.type === "mcp_tool_call_output") { + return requireSingleImage(parseMcpToolResult(value.result)); + } + + if ("output" in value) { + return requireSingleImage(parseToolOutput(value.output)); + } + + if ("content" in value) { + return requireSingleImage(parseContentItems(value.content)); + } + + throw new Error("codex.emitImage received an unsupported value"); +} + async function handleExec(message) { activeExecId = message.id; + const pendingBackgroundTasks = new Set(); const tool = (toolName, args) => { if (typeof toolName !== "string" || !toolName) { return Promise.reject(new Error("codex.tool expects a tool name string")); @@ -512,13 +762,57 @@ async function handleExec(message) { }); }); }; + const emitImage = (imageLike) => { + const operation = (async () => { + const normalized = normalizeEmitImageValue(await imageLike); + const id = `${message.id}-emit-image-${emitImageCounter++}`; + const payload = { + type: "emit_image", + id, + exec_id: message.id, + image_url: normalized.image_url, + detail: normalized.detail ?? null, + }; + send(payload); + return new Promise((resolve, reject) => { + pendingEmitImage.set(id, (res) => { + if (!res.ok) { + reject(new Error(res.error || "emitImage failed")); + return; + } + resolve(); + }); + }); + })(); + + const observation = { observed: false }; + const trackedOperation = operation.then( + () => ({ ok: true, error: null, observation }), + (error) => ({ ok: false, error, observation }), + ); + pendingBackgroundTasks.add(trackedOperation); + return { + then(onFulfilled, onRejected) { + observation.observed = true; + return operation.then(onFulfilled, onRejected); + }, + catch(onRejected) { + observation.observed = true; + return operation.catch(onRejected); + }, + finally(onFinally) { + observation.observed = true; + return operation.finally(onFinally); + }, + }; + }; try { const code = typeof message.code === "string" ? message.code : ""; const { source, nextBindings } = await buildModuleSource(code); let output = ""; - context.codex = { tmpDir, tool }; + context.codex = { tmpDir, tool, emitImage }; context.tmpDir = tmpDir; await withCapturedConsole(context, async (logs) => { @@ -558,6 +852,15 @@ async function handleExec(message) { }); await module.evaluate(); + if (pendingBackgroundTasks.size > 0) { + const backgroundResults = await Promise.all([...pendingBackgroundTasks]); + const firstUnhandledBackgroundError = backgroundResults.find( + (result) => !result.ok && !result.observation.observed, + ); + if (firstUnhandledBackgroundError) { + throw firstUnhandledBackgroundError.error; + } + } previousModule = module; previousBindings = nextBindings; output = logs.join("\n"); @@ -593,6 +896,14 @@ function handleToolResult(message) { } } +function handleEmitImageResult(message) { + const resolver = pendingEmitImage.get(message.id); + if (resolver) { + pendingEmitImage.delete(message.id); + resolver(message); + } +} + let queue = Promise.resolve(); process.on("uncaughtException", (error) => { @@ -622,5 +933,9 @@ input.on("line", (line) => { } if (message.type === "run_tool_result") { handleToolResult(message); + return; + } + if (message.type === "emit_image_result") { + handleEmitImageResult(message); } }); diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index ea9f42ce63..a8a9123073 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -12,6 +12,7 @@ use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use serde::Deserialize; use serde::Serialize; @@ -35,6 +36,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::exec::ExecExpiration; use crate::exec_env::create_env; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -372,18 +374,14 @@ impl JsReplManager { Some(state.cancel.clone()) } - async fn record_exec_tool_call_content_items( + async fn record_exec_content_item( exec_tool_calls: &Arc>>, exec_id: &str, - content_items: Vec, + content_item: FunctionCallOutputContentItem, ) { - if content_items.is_empty() { - return; - } - let mut calls = exec_tool_calls.lock().await; if let Some(state) = calls.get_mut(exec_id) { - state.content_items.extend(content_items); + state.content_items.push(content_item); } } @@ -1131,6 +1129,49 @@ impl JsReplManager { exec_contexts.lock().await.remove(&id); JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &id).await; } + KernelToHost::EmitImage(req) => { + let exec_id = req.exec_id.clone(); + let emit_id = req.id.clone(); + let response = + if let Some(ctx) = exec_contexts.lock().await.get(&exec_id).cloned() { + let content_item = emitted_image_content_item( + ctx.turn.as_ref(), + req.image_url, + req.detail, + ); + JsReplManager::record_exec_content_item( + &exec_tool_calls, + &exec_id, + content_item, + ) + .await; + HostToKernel::EmitImageResult(EmitImageResult { + id: emit_id, + ok: true, + error: None, + }) + } else { + HostToKernel::EmitImageResult(EmitImageResult { + id: emit_id, + ok: false, + error: Some("js_repl exec context not found".to_string()), + }) + }; + + if let Err(err) = JsReplManager::write_message(&stdin, &response).await { + let snapshot = + JsReplManager::kernel_debug_snapshot(&child, &recent_stderr).await; + warn!( + exec_id = %exec_id, + emit_id = %req.id, + error = %err, + kernel_pid = ?snapshot.pid, + kernel_status = %snapshot.status, + kernel_stderr_tail = %snapshot.stderr_tail, + "failed to reply to kernel emit_image request" + ); + } + } KernelToHost::RunTool(req) => { let Some(reset_cancel) = JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await @@ -1176,11 +1217,7 @@ impl JsReplManager { response: None, error: Some("js_repl execution reset".to_string()), }, - result = JsReplManager::run_tool_request( - ctx, - req, - Arc::clone(&exec_tool_calls_for_task), - ) => result, + result = JsReplManager::run_tool_request(ctx, req) => result, } } None => RunToolResult { @@ -1274,11 +1311,7 @@ impl JsReplManager { } } - async fn run_tool_request( - exec: ExecContext, - req: RunToolRequest, - exec_tool_calls: Arc>>, - ) -> RunToolResult { + async fn run_tool_request(exec: ExecContext, req: RunToolRequest) -> RunToolResult { if is_js_repl_internal_tool(&req.tool_name) { let error = "js_repl cannot invoke itself".to_string(); let summary = Self::summarize_tool_call_error(&error); @@ -1351,15 +1384,6 @@ impl JsReplManager { .await { Ok(response) => { - if let Some(items) = response_content_items(&response) { - Self::record_exec_tool_call_content_items( - &exec_tool_calls, - &req.exec_id, - items, - ) - .await; - } - let summary = Self::summarize_tool_call_response(&response); match serde_json::to_value(response) { Ok(value) => { @@ -1432,24 +1456,23 @@ impl JsReplManager { } } -fn response_content_items( - response: &ResponseInputItem, -) -> Option> { - match response { - ResponseInputItem::FunctionCallOutput { output, .. } - | ResponseInputItem::CustomToolCallOutput { output, .. } => output - .content_items() - .map(<[FunctionCallOutputContentItem]>::to_vec), - ResponseInputItem::McpToolCallOutput { result, .. } => match result { - Ok(result) => FunctionCallOutputPayload::from(result) - .content_items() - .map(<[FunctionCallOutputContentItem]>::to_vec), - Err(_) => None, - }, - ResponseInputItem::Message { .. } => None, +fn emitted_image_content_item( + turn: &TurnContext, + image_url: String, + detail: Option, +) -> FunctionCallOutputContentItem { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.or_else(|| default_output_image_detail_for_turn(turn)), } } +fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option { + (turn.config.features.enabled(Feature::ImageDetailOriginal) + && turn.model_info.supports_image_detail_original) + .then_some(ImageDetail::Original) +} + fn build_exec_result_content_items( output: String, content_items: Vec, @@ -1497,6 +1520,7 @@ enum KernelToHost { error: Option, }, RunTool(RunToolRequest), + EmitImage(EmitImageRequest), } #[derive(Clone, Debug, Serialize)] @@ -1509,6 +1533,7 @@ enum HostToKernel { timeout_ms: Option, }, RunToolResult(RunToolResult), + EmitImageResult(EmitImageResult), } #[derive(Clone, Debug, Deserialize)] @@ -1529,6 +1554,23 @@ struct RunToolResult { error: Option, } +#[derive(Clone, Debug, Deserialize)] +struct EmitImageRequest { + id: String, + exec_id: String, + image_url: String, + #[serde(default)] + detail: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct EmitImageResult { + id: String, + ok: bool, + #[serde(default)] + error: Option, +} + #[derive(Debug)] enum ExecResultMessage { Ok { @@ -1667,6 +1709,7 @@ mod tests { use super::*; use crate::codex::make_session_and_context; use crate::codex::make_session_and_context_with_dynamic_tools_and_rx; + use crate::features::Feature; use crate::protocol::AskForApproval; use crate::protocol::EventMsg; use crate::protocol::SandboxPolicy; @@ -1676,6 +1719,7 @@ mod tests { use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; use pretty_assertions::assert_eq; @@ -1923,6 +1967,43 @@ mod tests { ); } + #[tokio::test] + async fn emitted_image_content_item_preserves_explicit_detail() { + let (_session, turn) = make_session_and_context().await; + let content_item = emitted_image_content_item( + &turn, + "data:image/png;base64,AAA".to_string(), + Some(ImageDetail::Low), + ); + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(ImageDetail::Low), + } + ); + } + + #[tokio::test] + async fn emitted_image_content_item_uses_turn_original_detail_when_enabled() { + let (_session, mut turn) = make_session_and_context().await; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ImageDetailOriginal); + turn.model_info.supports_image_detail_original = true; + + let content_item = + emitted_image_content_item(&turn, "data:image/png;base64,AAA".to_string(), None); + + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(ImageDetail::Original), + } + ); + } + #[test] fn summarize_tool_call_response_for_multimodal_custom_output() { let response = ResponseInputItem::CustomToolCallOutput { @@ -2361,7 +2442,7 @@ console.log("cell-complete"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_can_attach_image_via_view_image_tool() -> anyhow::Result<()> { + async fn js_repl_does_not_auto_attach_image_via_view_image_tool() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { return Ok(()); } @@ -2398,7 +2479,65 @@ const png = Buffer.from( await fs.writeFile(imagePath, png); const out = await codex.tool("view_image", { path: imagePath }); console.log(out.type); -console.log(out.output?.body?.text ?? ""); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("function_call_output")); + assert!(result.content_items.is_empty()); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_can_emit_image_via_view_image_tool() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, mut turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + turn.approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + turn.sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const fs = await import("node:fs/promises"); +const path = await import("node:path"); +const imagePath = path.join(codex.tmpDir, "js-repl-view-image-explicit.png"); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await fs.writeFile(imagePath, png); +const out = await codex.tool("view_image", { path: imagePath }); +await codex.emitImage(out); +console.log(out.type); "#; let result = manager @@ -2423,20 +2562,374 @@ console.log(out.output?.body?.text ?? ""); }] .as_slice() ); - let [FunctionCallOutputContentItem::InputImage { image_url, detail }] = - result.content_items.as_slice() - else { - panic!("view_image should return exactly one input_image content item"); - }; - assert!(image_url.starts_with("data:image/png;base64,")); - assert_eq!(*detail, None); assert!(session.get_pending_input().await.is_empty()); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn js_repl_can_attach_image_via_dynamic_tool_with_mixed_content() -> anyhow::Result<()> { + async fn js_repl_can_emit_image_from_bytes_and_mime_type() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png, mimeType: "image/png" }); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_can_emit_multiple_images_in_one_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +await codex.emitImage( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" +); +await codex.emitImage( + "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" +); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [ + FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }, + FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" + .to_string(), + detail: None, + }, + ] + .as_slice() + ); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_waits_for_unawaited_emit_image_before_completion() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +void codex.emitImage( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" +); +console.log("cell-complete"); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("cell-complete")); + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" + .to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_unawaited_emit_image_errors_fail_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +void codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); +console.log("cell-complete"); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("unawaited invalid emitImage should fail"); + assert!(err.to_string().contains("expected non-empty bytes")); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_caught_emit_image_error_does_not_fail_cell() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +try { + await codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); +} catch (error) { + console.log(error.message); +} +console.log("cell-complete"); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert!(result.output.contains("expected non-empty bytes")); + assert!(result.output.contains("cell-complete")); + assert!(result.content_items.is_empty()); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_emit_image_requires_explicit_mime_type_for_bytes() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png }); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("missing mimeType should fail"); + assert!(err.to_string().contains("expected a non-empty mimeType")); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); +"#; + + let err = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await + .expect_err("invalid detail should fail"); + assert!(err.to_string().contains("expected detail to be one of")); + assert!(session.get_pending_input().await.is_empty()); + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { return Ok(()); } @@ -2466,7 +2959,7 @@ console.log(out.output?.body?.text ?? ""); let manager = turn.js_repl.manager().await?; let code = r#" const out = await codex.tool("inline_image", {}); -console.log(out.type); +await codex.emitImage(out); "#; let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; @@ -2509,19 +3002,10 @@ console.log(out.type); response_watcher, ); response_watcher_result?; - let result = result?; - assert!(result.output.contains("function_call_output")); - assert_eq!( - result.content_items, - vec![ - FunctionCallOutputContentItem::InputText { - text: "inline image note".to_string(), - }, - FunctionCallOutputContentItem::InputImage { - image_url: image_url.to_string(), - detail: None, - }, - ] + let err = result.expect_err("mixed content should fail"); + assert!( + err.to_string() + .contains("does not accept mixed text and image content") ); assert!(session.get_pending_input().await.is_empty()); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d4a8ce9f3e..d6fde7e691 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -21,7 +21,9 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; @@ -485,6 +487,127 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { assert!(legacy_image_index < new_user_index); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_replays_image_tool_outputs_with_detail() { + skip_if_no_network!(); + + let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; + let function_call_id = "view-image-call"; + let custom_call_id = "js-repl-call"; + let rollout = vec![ + RolloutLine { + timestamp: "2024-01-01T00:00:00.000Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: ThreadId::default(), + timestamp: "2024-01-01T00:00:00Z".to_string(), + cwd: ".".into(), + originator: "test_originator".to_string(), + cli_version: "test_version".to_string(), + model_provider: Some("test-provider".to_string()), + ..Default::default() + }, + git: None, + }), + }, + RolloutLine { + timestamp: "2024-01-01T00:00:01.000Z".to_string(), + item: RolloutItem::ResponseItem(ResponseItem::FunctionCall { + id: None, + name: "view_image".to_string(), + arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), + call_id: function_call_id.to_string(), + }), + }, + RolloutLine { + timestamp: "2024-01-01T00:00:01.500Z".to_string(), + item: RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput { + call_id: function_call_id.to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: image_url.to_string(), + detail: Some(ImageDetail::Original), + }, + ]), + }), + }, + RolloutLine { + timestamp: "2024-01-01T00:00:02.000Z".to_string(), + item: RolloutItem::ResponseItem(ResponseItem::CustomToolCall { + id: None, + status: Some("completed".to_string()), + call_id: custom_call_id.to_string(), + name: "js_repl".to_string(), + input: "console.log('image flow')".to_string(), + }), + }, + RolloutLine { + timestamp: "2024-01-01T00:00:02.500Z".to_string(), + item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput { + call_id: custom_call_id.to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: image_url.to_string(), + detail: Some(ImageDetail::Original), + }, + ]), + }), + }, + ]; + + let tmpdir = TempDir::new().unwrap(); + let session_path = tmpdir + .path() + .join("resume-image-tool-outputs-with-detail.jsonl"); + let mut file = std::fs::File::create(&session_path).unwrap(); + for line in rollout { + writeln!(file, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + } + + let server = MockServer::start().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let codex_home = Arc::new(TempDir::new().unwrap()); + let mut builder = test_codex().with_model("gpt-5.1"); + let test = builder + .resume(&server, codex_home, session_path.clone()) + .await + .expect("resume conversation"); + test.submit_turn("after resume").await.unwrap(); + + let function_output = resp_mock + .single_request() + .function_call_output(function_call_id); + assert_eq!( + function_output.get("output"), + Some(&serde_json::json!([ + { + "type": "input_image", + "image_url": image_url, + "detail": "original" + } + ])) + ); + + let custom_output = resp_mock + .single_request() + .custom_tool_call_output(custom_call_id); + assert_eq!( + custom_output.get("output"), + Some(&serde_json::json!([ + { + "type": "input_image", + "image_url": image_url, + "detail": "original" + } + ])) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_conversation_id_and_model_headers_in_request() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 9b73afbc1b..b56e28d020 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -494,7 +494,7 @@ async fn view_image_tool_keeps_legacy_behavior_below_gpt5_3_codex() -> anyhow::R } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_view_image_tool_attaches_local_image() -> anyhow::Result<()> { +async fn js_repl_emit_image_attaches_local_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -519,7 +519,7 @@ const png = Buffer.from( ); await fs.writeFile(imagePath, png); const out = await codex.tool("view_image", { path: imagePath }); -console.log(out.output?.body?.text ?? ""); +await codex.emitImage(out); "#; let first_response = sse(vec![ @@ -555,12 +555,29 @@ console.log(out.output?.body?.text ?? ""); }) .await?; + let mut tool_event = None; wait_for_event_with_timeout( &codex, - |event| matches!(event, EventMsg::TurnComplete(_)), + |event| match event { + EventMsg::ViewImageToolCall(_) => { + tool_event = Some(event.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }, Duration::from_secs(10), ) .await; + let tool_event = match tool_event { + Some(EventMsg::ViewImageToolCall(event)) => event, + other => panic!("expected ViewImageToolCall event, got {other:?}"), + }; + assert!( + tool_event.path.ends_with("js-repl-view-image.png"), + "unexpected image path: {}", + tool_event.path.display() + ); let req = mock.single_request(); let body = req.body_json(); @@ -591,6 +608,105 @@ console.log(out.output?.body?.text ?? ""); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_view_image_requires_explicit_emit() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::JsRepl); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "js-repl-view-image-no-emit"; + let js_input = r#" +const fs = await import("node:fs/promises"); +const path = await import("node:path"); +const imagePath = path.join(codex.tmpDir, "js-repl-view-image-no-emit.png"); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await fs.writeFile(imagePath, png); +const out = await codex.tool("view_image", { path: imagePath }); +console.log(out.type); +"#; + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call(call_id, "js_repl", js_input), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "use js_repl to write an image but do not emit it".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut tool_event = None; + wait_for_event_with_timeout( + &codex, + |event| match event { + EventMsg::ViewImageToolCall(_) => { + tool_event = Some(event.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }, + Duration::from_secs(10), + ) + .await; + let tool_event = match tool_event { + Some(EventMsg::ViewImageToolCall(event)) => event, + other => panic!("expected ViewImageToolCall event, got {other:?}"), + }; + assert!( + tool_event.path.ends_with("js-repl-view-image-no-emit.png"), + "unexpected image path: {}", + tool_event.path.display() + ); + + let req = mock.single_request(); + let custom_output = req.custom_tool_call_output(call_id); + let output_items = custom_output.get("output").and_then(Value::as_array); + assert!( + output_items.is_none_or(|items| items + .iter() + .all(|item| item.get("type").and_then(Value::as_str) != Some("input_image"))), + "nested view_image should not auto-populate js_repl output" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/docs/js_repl.md b/docs/js_repl.md index b36da5dc08..dd33902900 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -65,8 +65,13 @@ For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module reso - `codex.tmpDir`: per-session scratch directory path. - `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available). +- `codex.emitImage(imageLike)`: explicitly adds exactly one image to the outer `js_repl` function output. - Each `codex.tool(...)` call emits a bounded summary at `info` level from the `codex_core::tools::js_repl` logger. At `trace` level, the same path also logs the exact raw response object or error string seen by JavaScript. -- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool("view_image", { path: "/absolute/path" })`, then delete the file. +- Nested `codex.tool(...)` outputs stay inside JavaScript unless you emit them explicitly. +- `codex.emitImage(...)` accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object that contains exactly one image and no text. +- `codex.emitImage(...)` rejects mixed text-and-image content. +- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg" })`. +- Example of sharing a local image tool result: `await codex.emitImage(codex.tool("view_image", { path: "/absolute/path" }))`. Avoid writing directly to `process.stdout` / `process.stderr` / `process.stdin`; the kernel uses a JSON-line transport over stdio. From 082682a62871bb14864cfabd54b88368a9830b90 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 3 Mar 2026 16:29:15 -0800 Subject: [PATCH 06/23] feat: load plugin apps (#13401) load plugin-apps from `.app.json`. make apps runtime-mentionable iff `codex_apps` MCP actually exposes tools for that `connector_id`. if the app isn't available, it's filtered out of runtime connector set, so no tools are added and no app-mentions resolve. right now we don't have a clean cli-side error for an app not being installed. can look at this after. ### Tests Added tests, tested locally that using a plugin that bundles an app picks up the app. --- codex-rs/chatgpt/src/connectors.rs | 19 ++- codex-rs/core/src/codex.rs | 21 ++- codex-rs/core/src/connectors.rs | 106 ++++++++++++ codex-rs/core/src/plugins/manager.rs | 160 ++++++++++++++++++ codex-rs/core/src/plugins/mod.rs | 1 + .../core/tests/common/apps_test_server.rs | 21 ++- codex-rs/core/tests/suite/plugins.rs | 129 ++++++++++++++ 7 files changed, 444 insertions(+), 13 deletions(-) diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 620a93872e..81c382f5d6 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -26,7 +26,9 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_o pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; use codex_core::connectors::merge_connectors; +use codex_core::connectors::merge_plugin_apps; pub use codex_core::connectors::with_app_enabled_state; +use codex_core::plugins::PluginsManager; #[derive(Debug, Deserialize)] struct DirectoryListResponse { @@ -106,7 +108,10 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let token_data = get_chatgpt_token_data()?; let cache_key = all_connectors_cache_key(config, &token_data); - read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors) + read_cached_all_connectors(&cache_key).map(|connectors| { + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + filter_disallowed_connectors(connectors) + }) } pub async fn list_all_connectors_with_options( @@ -123,7 +128,8 @@ pub async fn list_all_connectors_with_options( get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; let cache_key = all_connectors_cache_key(config, &token_data); if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { - return Ok(filter_disallowed_connectors(cached_connectors)); + let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config)); + return Ok(filter_disallowed_connectors(connectors)); } let mut apps = list_directory_connectors(config).await?; @@ -151,7 +157,8 @@ pub async fn list_all_connectors_with_options( }); let connectors = filter_disallowed_connectors(connectors); write_cached_all_connectors(cache_key, &connectors); - Ok(connectors) + let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config)); + Ok(filter_disallowed_connectors(connectors)) } fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey { @@ -192,6 +199,12 @@ fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[A }); } +fn plugin_apps_for_config(config: &Config) -> Vec { + PluginsManager::new(config.codex_home.clone()) + .plugins_for_config(config) + .effective_apps() +} + pub fn merge_connectors_with_accessible( connectors: Vec, accessible_connectors: Vec, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7736aa1280..1ead1871f5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4891,10 +4891,15 @@ pub(crate) async fn run_turn( Ok(mcp_tools) => mcp_tools, Err(_) => return None, }; - connectors::with_app_enabled_state( + let plugin_apps = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); + let connectors = connectors::merge_plugin_apps_with_accessible( + plugin_apps.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn_context.config, - ) + ); + connectors::with_app_enabled_state(connectors, &turn_context.config) } else { Vec::new() }; @@ -5614,8 +5619,16 @@ async fn built_tools( effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await); let connectors = if turn_context.features.enabled(Feature::Apps) { - Some(connectors::with_app_enabled_state( + let plugin_apps = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config); + let connectors = connectors::merge_plugin_apps_with_accessible( + plugin_apps.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + ); + Some(connectors::with_app_enabled_state( + connectors, &turn_context.config, )) } else { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 33b003770e..3ad48fa008 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::env; use std::path::PathBuf; use std::sync::LazyLock; @@ -29,6 +30,7 @@ use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; +use crate::plugins::AppConnectorId; use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); @@ -353,6 +355,48 @@ pub fn merge_connectors( merged } +pub fn merge_plugin_apps( + connectors: Vec, + plugin_apps: Vec, +) -> Vec { + let mut merged = connectors; + let mut connector_ids = merged + .iter() + .map(|connector| connector.id.clone()) + .collect::>(); + + for connector_id in plugin_apps { + if connector_ids.insert(connector_id.0.clone()) { + merged.push(plugin_app_to_app_info(connector_id)); + } + } + + merged.sort_by(|left, right| { + right + .is_accessible + .cmp(&left.is_accessible) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + merged +} + +pub fn merge_plugin_apps_with_accessible( + plugin_apps: Vec, + accessible_connectors: Vec, +) -> Vec { + let accessible_connector_ids: HashSet<&str> = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect(); + let plugin_connectors = plugin_apps + .into_iter() + .filter(|connector_id| accessible_connector_ids.contains(connector_id.0.as_str())) + .map(plugin_app_to_app_info) + .collect::>(); + merge_connectors(plugin_connectors, accessible_connectors) +} + pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { let apps_config = read_apps_config(config); if let Some(apps_config) = apps_config.as_ref() { @@ -575,6 +619,28 @@ where accessible } +fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo { + // Leave the placeholder name as the connector id so merge_connectors() can + // replace it with canonical app metadata from directory fetches or + // connector_name values from codex_apps tool discovery. + let connector_id = connector_id.0; + let name = connector_id.clone(); + AppInfo { + id: connector_id.clone(), + name: name.clone(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url(&name, &connector_id)), + is_accessible: false, + is_enabled: true, + } +} + fn normalize_connector_value(value: Option<&str>) -> Option { value .map(str::trim) @@ -647,6 +713,46 @@ mod tests { } } + #[test] + fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string())); + let accessible = AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + }; + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + }] + ); + assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); + } + #[test] fn app_tool_policy_uses_global_defaults_for_destructive_hints() { let apps_config = AppsConfigToml { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d98c4379ff..c5a7b894af 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -33,6 +33,10 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; +const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AppConnectorId(pub String); #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { @@ -42,6 +46,7 @@ pub struct LoadedPlugin { pub enabled: bool, pub skill_roots: Vec, pub mcp_servers: HashMap, + pub apps: Vec, pub error: Option, } @@ -80,6 +85,21 @@ impl PluginLoadOutcome { } mcp_servers } + + pub fn effective_apps(&self) -> Vec { + let mut apps = Vec::new(); + let mut seen_connector_ids = std::collections::HashSet::new(); + + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for connector_id in &plugin.apps { + if seen_connector_ids.insert(connector_id.clone()) { + apps.push(connector_id.clone()); + } + } + } + + apps + } } pub struct PluginsManager { @@ -227,6 +247,18 @@ struct PluginMcpFile { mcp_servers: HashMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginAppFile { + #[serde(default)] + apps: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +struct PluginAppConfig { + id: String, +} + pub(crate) fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, @@ -299,6 +331,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) enabled: plugin.enabled, skill_roots: Vec::new(), mcp_servers: HashMap::new(), + apps: Vec::new(), error: None, }; @@ -341,6 +374,10 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) } } loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.apps = load_apps_from_file( + plugin_root.as_path(), + &plugin_root.as_path().join(DEFAULT_APP_CONFIG_FILE), + ); loaded_plugin } @@ -364,6 +401,39 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { paths } +fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec { + let Ok(contents) = fs::read_to_string(app_config_path) else { + return Vec::new(); + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + path = %app_config_path.display(), + "failed to parse plugin app config: {err}" + ); + return Vec::new(); + } + }; + + let mut apps: Vec = parsed.apps.into_values().collect(); + apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + + apps.into_iter() + .filter_map(|app| { + if app.id.trim().is_empty() { + warn!( + plugin = %plugin_root.display(), + "plugin app config is missing an app id" + ); + None + } else { + Some(AppConnectorId(app.id)) + } + }) + .collect() +} + fn load_mcp_servers_from_file(plugin_root: &Path, mcp_config_path: &Path) -> PluginMcpDiscovery { let Ok(contents) = fs::read_to_string(mcp_config_path) else { return PluginMcpDiscovery::default(); @@ -550,6 +620,16 @@ mod tests { } }"#, ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()).await; @@ -582,6 +662,7 @@ mod tests { oauth_resource: None, }, )]), + apps: vec![AppConnectorId("connector_example".to_string())], error: None, }] ); @@ -590,6 +671,10 @@ mod tests { vec![plugin_root.join("skills")] ); assert_eq!(outcome.effective_mcp_servers().len(), 1); + assert_eq!( + outcome.effective_apps(), + vec![AppConnectorId("connector_example".to_string())] + ); } #[tokio::test] @@ -628,6 +713,7 @@ mod tests { enabled: false, skill_roots: Vec::new(), mcp_servers: HashMap::new(), + apps: Vec::new(), error: None, }] ); @@ -635,6 +721,80 @@ mod tests { assert!(outcome.effective_mcp_servers().is_empty()); } + #[tokio::test] + async fn effective_apps_dedupes_connector_ids_across_plugins() { + let codex_home = TempDir::new().unwrap(); + let plugin_a_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-a/local"); + let plugin_b_root = codex_home + .path() + .join("plugins/cache") + .join("test/plugin-b/local"); + + write_file( + &plugin_a_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-a"}"#, + ); + write_file( + &plugin_a_root.join(".app.json"), + r#"{ + "apps": { + "example": { + "id": "connector_example" + } + } +}"#, + ); + write_file( + &plugin_b_root.join(".codex-plugin/plugin.json"), + r#"{"name":"plugin-b"}"#, + ); + write_file( + &plugin_b_root.join(".app.json"), + r#"{ + "apps": { + "chat": { + "id": "connector_example" + }, + "gmail": { + "id": "connector_gmail" + } + } +}"#, + ); + + let mut root = toml::map::Map::new(); + let mut features = toml::map::Map::new(); + features.insert("plugins".to_string(), Value::Boolean(true)); + root.insert("features".to_string(), Value::Table(features)); + + let mut plugins = toml::map::Map::new(); + + let mut plugin_a = toml::map::Map::new(); + plugin_a.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a)); + + let mut plugin_b = toml::map::Map::new(); + plugin_b.insert("enabled".to_string(), Value::Boolean(true)); + plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b)); + + root.insert("plugins".to_string(), Value::Table(plugins)); + let config_toml = + toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); + + let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await; + + assert_eq!( + outcome.effective_apps(), + vec![ + AppConnectorId("connector_example".to_string()), + AppConnectorId("connector_gmail".to_string()), + ] + ); + } + #[test] fn plugin_namespace_for_skill_path_uses_manifest_name() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index faf0a8dd0c..37b4009bc0 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -2,6 +2,7 @@ mod manager; mod manifest; mod store; +pub use manager::AppConnectorId; pub use manager::LoadedPlugin; pub use manager::PluginInstallError; pub use manager::PluginLoadOutcome; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index c562e390e1..1160a125a1 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -23,8 +23,15 @@ pub struct AppsTestServer { impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { + Self::mount_with_connector_name(server, CONNECTOR_NAME).await + } + + pub async fn mount_with_connector_name( + server: &MockServer, + connector_name: &str, + ) -> Result { mount_oauth_metadata(server).await; - mount_streamable_http_json_rpc(server).await; + mount_streamable_http_json_rpc(server, connector_name.to_string()).await; Ok(Self { chatgpt_base_url: server.uri(), }) @@ -43,15 +50,17 @@ async fn mount_oauth_metadata(server: &MockServer) { .await; } -async fn mount_streamable_http_json_rpc(server: &MockServer) { +async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) - .respond_with(CodexAppsJsonRpcResponder) + .respond_with(CodexAppsJsonRpcResponder { connector_name }) .mount(server) .await; } -struct CodexAppsJsonRpcResponder; +struct CodexAppsJsonRpcResponder { + connector_name: String, +} impl Respond for CodexAppsJsonRpcResponder { fn respond(&self, request: &Request) -> ResponseTemplate { @@ -117,7 +126,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": CONNECTOR_NAME + "connector_name": self.connector_name.clone() } }, { @@ -133,7 +142,7 @@ impl Respond for CodexAppsJsonRpcResponder { }, "_meta": { "connector_id": CONNECTOR_ID, - "connector_name": CONNECTOR_NAME + "connector_name": self.connector_name.clone() } } ], diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 3e641d010d..12d81402c8 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -7,11 +7,14 @@ use std::time::Instant; use anyhow::Result; use codex_core::CodexAuth; +use codex_core::features::Feature; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use core_test_support::apps_test_server::AppsTestServer; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -74,6 +77,35 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { .expect("write config"); } +fn write_plugin_app_plugin(home: &TempDir) { + let plugin_root = home.path().join("plugins/sample"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write plugin manifest"); + std::fs::write( + plugin_root.join(".app.json"), + r#"{ + "apps": { + "calendar": { + "id": "calendar" + } + } +}"#, + ) + .expect("write plugin app config"); + std::fs::write( + home.path().join("config.toml"), + format!( + "[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n", + plugin_root.display() + ), + ) + .expect("write config"); +} + async fn build_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -88,6 +120,23 @@ async fn build_plugin_test_codex( .codex) } +fn tool_names(body: &serde_json::Value) -> Vec { + body.get("tools") + .and_then(serde_json::Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn plugin_skills_append_to_instructions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -138,6 +187,86 @@ async fn plugin_skills_append_to_instructions() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let codex_home = Arc::new(TempDir::new()?); + write_plugin_app_plugin(codex_home.as_ref()); + let mut builder = test_codex() + .with_home(codex_home) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.features.enable(Feature::Apps); + config.features.disable(Feature::AppsMcpGateway); + config.chatgpt_base_url = apps_server.chatgpt_base_url; + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "Use $google-calendar and then call tools.".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let first_tools = tool_names(&requests[0].body_json()); + assert!( + !first_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_create_event"), + "app tools should stay hidden before plugin app mention: {first_tools:?}" + ); + + let second_tools = tool_names(&requests[1].body_json()); + assert!( + second_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_create_event"), + "calendar create tool should be available after plugin app mention: {second_tools:?}" + ); + assert!( + second_tools + .iter() + .any(|name| name == "mcp__codex_apps__calendar_list_events"), + "calendar list tool should be available after plugin app mention: {second_tools:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn plugin_mcp_tools_are_listed() -> Result<()> { skip_if_no_network!(Ok(())); From e951ef43741628a4835dceaf61df297c504b7281 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 3 Mar 2026 16:34:11 -0800 Subject: [PATCH 07/23] [feedback] diagnostics (#13292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - added header logic to display diagnostics on cli - added logic for collecting env vars Screenshot 2026-03-03 at 3 49 31 PM Screenshot 2026-03-02 at 6 47 54 PM --- codex-rs/Cargo.lock | 1 + codex-rs/feedback/Cargo.toml | 1 + codex-rs/feedback/src/feedback_diagnostics.rs | 247 ++++++++++++++++++ codex-rs/feedback/src/lib.rs | 146 +++++++++-- codex-rs/tui/src/bottom_pane/feedback_view.rs | 162 ++++++++++-- ...ck_view_with_connectivity_diagnostics.snap | 17 ++ codex-rs/tui/src/chatwidget.rs | 14 +- ...s__feedback_good_result_consent_popup.snap | 15 ++ ..._tests__feedback_upload_consent_popup.snap | 1 + codex-rs/tui/src/chatwidget/tests.rs | 23 +- 10 files changed, 586 insertions(+), 41 deletions(-) create mode 100644 codex-rs/feedback/src/feedback_diagnostics.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_with_connectivity_diagnostics.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b0c1e38429..48e8c598db 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1999,6 +1999,7 @@ dependencies = [ "sentry", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml index 73803af86a..43d572f895 100644 --- a/codex-rs/feedback/Cargo.toml +++ b/codex-rs/feedback/Cargo.toml @@ -10,6 +10,7 @@ codex-protocol = { workspace = true } sentry = { version = "0.46" } tracing = { workspace = true } tracing-subscriber = { workspace = true } +url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/feedback/src/feedback_diagnostics.rs b/codex-rs/feedback/src/feedback_diagnostics.rs new file mode 100644 index 0000000000..5497fe0579 --- /dev/null +++ b/codex-rs/feedback/src/feedback_diagnostics.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; + +use url::Url; + +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL"; +pub const FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME: &str = "codex-connectivity-diagnostics.txt"; +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", +]; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FeedbackDiagnostics { + diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeedbackDiagnostic { + pub headline: String, + pub details: Vec, +} + +impl FeedbackDiagnostics { + pub fn new(diagnostics: Vec) -> Self { + Self { diagnostics } + } + + pub fn collect_from_env() -> Self { + Self::collect_from_pairs(std::env::vars()) + } + + fn collect_from_pairs(pairs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + let env = pairs + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect::>(); + let mut diagnostics = Vec::new(); + + let proxy_details = PROXY_ENV_VARS + .iter() + .filter_map(|key| { + let value = env.get(*key)?.trim(); + if value.is_empty() { + return None; + } + + let detail = match sanitize_proxy_value(value) { + Some(sanitized) => format!("{key} = {sanitized}"), + None => format!("{key} = invalid value"), + }; + Some(detail) + }) + .collect::>(); + if !proxy_details.is_empty() { + diagnostics.push(FeedbackDiagnostic { + headline: "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: proxy_details, + }); + } + + if let Some(value) = env.get(OPENAI_BASE_URL_ENV_VAR).map(String::as_str) { + let trimmed = value.trim(); + if !trimmed.is_empty() && trimmed.trim_end_matches('/') != DEFAULT_OPENAI_BASE_URL { + let detail = match sanitize_url_for_display(trimmed) { + Some(sanitized) => format!("{OPENAI_BASE_URL_ENV_VAR} = {sanitized}"), + None => format!("{OPENAI_BASE_URL_ENV_VAR} = invalid value"), + }; + diagnostics.push(FeedbackDiagnostic { + headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(), + details: vec![detail], + }); + } + } + + Self { diagnostics } + } + + pub fn is_empty(&self) -> bool { + self.diagnostics.is_empty() + } + + pub fn diagnostics(&self) -> &[FeedbackDiagnostic] { + &self.diagnostics + } + + pub fn attachment_text(&self) -> Option { + if self.diagnostics.is_empty() { + return None; + } + + let mut lines = vec!["Connectivity diagnostics".to_string(), String::new()]; + for diagnostic in &self.diagnostics { + lines.push(format!("- {}", diagnostic.headline)); + lines.extend( + diagnostic + .details + .iter() + .map(|detail| format!(" - {detail}")), + ); + } + + Some(lines.join("\n")) + } +} + +pub fn sanitize_url_for_display(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let Ok(mut url) = Url::parse(trimmed) else { + return None; + }; + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty()) +} + +fn sanitize_proxy_value(raw: &str) -> Option { + if raw.contains("://") { + return sanitize_url_for_display(raw); + } + + sanitize_url_for_display(&format!("http://{raw}")) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::FeedbackDiagnostic; + use super::FeedbackDiagnostics; + use super::sanitize_url_for_display; + + #[test] + fn collect_from_pairs_reports_sanitized_diagnostics_and_attachment() { + let diagnostics = FeedbackDiagnostics::collect_from_pairs([ + ( + "HTTPS_PROXY", + "https://user:password@secure-proxy.example.com:443?secret=1", + ), + ("http_proxy", "proxy.example.com:8080"), + ("all_proxy", "socks5h://all-proxy.example.com:1080"), + ("OPENAI_BASE_URL", "https://example.com/v1?token=secret"), + ]); + + assert_eq!( + diagnostics, + FeedbackDiagnostics { + diagnostics: vec![ + FeedbackDiagnostic { + headline: + "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec![ + "http_proxy = http://proxy.example.com:8080".to_string(), + "HTTPS_PROXY = https://secure-proxy.example.com".to_string(), + "all_proxy = socks5h://all-proxy.example.com:1080".to_string(), + ], + }, + FeedbackDiagnostic { + headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(), + details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()], + }, + ], + } + ); + + assert_eq!( + diagnostics.attachment_text(), + Some( + "Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - http_proxy = http://proxy.example.com:8080\n - HTTPS_PROXY = https://secure-proxy.example.com\n - all_proxy = socks5h://all-proxy.example.com:1080\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1" + .to_string() + ) + ); + } + + #[test] + fn collect_from_pairs_ignores_absent_and_default_values() { + for diagnostics in [ + FeedbackDiagnostics::collect_from_pairs(Vec::<(String, String)>::new()), + FeedbackDiagnostics::collect_from_pairs([( + "OPENAI_BASE_URL", + "https://api.openai.com/v1/", + )]), + ] { + assert_eq!(diagnostics, FeedbackDiagnostics::default()); + assert_eq!(diagnostics.attachment_text(), None); + } + } + + #[test] + fn collect_from_pairs_reports_invalid_values_without_echoing_them() { + let invalid_proxy = "not a valid\nproxy"; + let invalid_base_url = "not a valid\nurl"; + let diagnostics = FeedbackDiagnostics::collect_from_pairs([ + ("HTTP_PROXY", invalid_proxy), + ("OPENAI_BASE_URL", invalid_base_url), + ]); + + assert_eq!( + diagnostics, + FeedbackDiagnostics { + diagnostics: vec![ + FeedbackDiagnostic { + headline: + "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec!["HTTP_PROXY = invalid value".to_string()], + }, + FeedbackDiagnostic { + headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(), + details: vec!["OPENAI_BASE_URL = invalid value".to_string()], + }, + ], + } + ); + let attachment_text = diagnostics + .attachment_text() + .expect("invalid diagnostics should still render attachment text"); + assert!(!attachment_text.contains(invalid_proxy)); + assert!(!attachment_text.contains(invalid_base_url)); + } + + #[test] + fn sanitize_url_for_display_strips_credentials_query_and_fragment() { + let sanitized = sanitize_url_for_display( + "https://user:password@example.com:8443/v1?token=secret#fragment", + ); + + assert_eq!(sanitized, Some("https://example.com:8443/v1".to_string())); + } +} diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index ee8e98c39e..0bd76936aa 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -13,6 +13,8 @@ use anyhow::Result; use anyhow::anyhow; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; +use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; +use feedback_diagnostics::FeedbackDiagnostics; use tracing::Event; use tracing::Level; use tracing::field::Visit; @@ -21,6 +23,8 @@ use tracing_subscriber::filter::Targets; use tracing_subscriber::fmt::writer::MakeWriter; use tracing_subscriber::registry::LookupSpan; +pub mod feedback_diagnostics; + const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; @@ -88,7 +92,7 @@ impl CodexFeedback { .with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE)) } - pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot { + pub fn snapshot(&self, session_id: Option) -> FeedbackSnapshot { let bytes = { let guard = self.inner.ring.lock().expect("mutex poisoned"); guard.snapshot_bytes() @@ -97,9 +101,10 @@ impl CodexFeedback { let guard = self.inner.tags.lock().expect("mutex poisoned"); guard.clone() }; - CodexLogSnapshot { + FeedbackSnapshot { bytes, tags, + feedback_diagnostics: FeedbackDiagnostics::collect_from_env(), thread_id: session_id .map(|id| id.to_string()) .unwrap_or("no-active-thread-".to_string() + &ThreadId::new().to_string()), @@ -199,17 +204,35 @@ impl RingBuffer { } } -pub struct CodexLogSnapshot { +pub struct FeedbackSnapshot { bytes: Vec, tags: BTreeMap, + feedback_diagnostics: FeedbackDiagnostics, pub thread_id: String, } -impl CodexLogSnapshot { +impl FeedbackSnapshot { pub(crate) fn as_bytes(&self) -> &[u8] { &self.bytes } + pub fn feedback_diagnostics(&self) -> &FeedbackDiagnostics { + &self.feedback_diagnostics + } + + pub fn with_feedback_diagnostics(mut self, feedback_diagnostics: FeedbackDiagnostics) -> Self { + self.feedback_diagnostics = feedback_diagnostics; + self + } + + pub fn feedback_diagnostics_attachment_text(&self, include_logs: bool) -> Option { + if !include_logs { + return None; + } + + self.feedback_diagnostics.attachment_text() + } + pub fn save_to_temp_file(&self) -> io::Result { let dir = std::env::temp_dir(); let filename = format!("codex-feedback-{}.log", self.thread_id); @@ -224,18 +247,16 @@ impl CodexLogSnapshot { classification: &str, reason: Option<&str>, include_logs: bool, - extra_log_files: &[PathBuf], + extra_attachment_paths: &[PathBuf], session_source: Option, logs_override: Option>, ) -> Result<()> { use std::collections::BTreeMap; - use std::fs; use std::str::FromStr; use std::sync::Arc; use sentry::Client; use sentry::ClientOptions; - use sentry::protocol::Attachment; use sentry::protocol::Envelope; use sentry::protocol::EnvelopeItem; use sentry::protocol::Event; @@ -309,16 +330,46 @@ impl CodexLogSnapshot { } envelope.add_item(EnvelopeItem::Event(event)); + for attachment in + self.feedback_attachments(include_logs, extra_attachment_paths, logs_override) + { + envelope.add_item(EnvelopeItem::Attachment(attachment)); + } + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + Ok(()) + } + + fn feedback_attachments( + &self, + include_logs: bool, + extra_attachment_paths: &[PathBuf], + logs_override: Option>, + ) -> Vec { + use sentry::protocol::Attachment; + + let mut attachments = Vec::new(); + if include_logs { - envelope.add_item(EnvelopeItem::Attachment(Attachment { + attachments.push(Attachment { buffer: logs_override.unwrap_or_else(|| self.bytes.clone()), filename: String::from("codex-logs.log"), content_type: Some("text/plain".to_string()), ty: None, - })); + }); } - for path in extra_log_files { + if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) { + attachments.push(Attachment { + buffer: text.into_bytes(), + filename: FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.to_string(), + content_type: Some("text/plain".to_string()), + ty: None, + }); + } + + for path in extra_attachment_paths { let data = match fs::read(path) { Ok(data) => data, Err(err) => { @@ -330,22 +381,19 @@ impl CodexLogSnapshot { continue; } }; - let fname = path + let filename = path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "extra-log.log".to_string()); - let content_type = "text/plain".to_string(); - envelope.add_item(EnvelopeItem::Attachment(Attachment { + attachments.push(Attachment { buffer: data, - filename: fname, - content_type: Some(content_type), + filename, + content_type: Some("text/plain".to_string()), ty: None, - })); + }); } - client.send_envelope(envelope); - client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); - Ok(()) + attachments } } @@ -430,7 +478,12 @@ impl Visit for FeedbackTagsVisitor { #[cfg(test)] mod tests { + use std::ffi::OsStr; + use std::fs; + use super::*; + use feedback_diagnostics::FeedbackDiagnostic; + use pretty_assertions::assert_eq; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -460,4 +513,59 @@ mod tests { pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5")); pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true")); } + + #[test] + fn feedback_attachments_gate_connectivity_diagnostics() { + let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new()); + let extra_path = std::env::temp_dir().join(&extra_filename); + fs::write(&extra_path, "rollout").expect("extra attachment should be written"); + + let snapshot_with_diagnostics = CodexFeedback::new() + .snapshot(None) + .with_feedback_diagnostics(FeedbackDiagnostics::new(vec![FeedbackDiagnostic { + headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(), + details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()], + }])); + + let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments( + true, + std::slice::from_ref(&extra_path), + Some(vec![1]), + ); + + assert_eq!( + attachments_with_diagnostics + .iter() + .map(|attachment| attachment.filename.as_str()) + .collect::>(), + vec![ + "codex-logs.log", + FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, + extra_filename.as_str() + ] + ); + assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); + assert_eq!( + attachments_with_diagnostics[1].buffer, + b"Connectivity diagnostics\n\n- OPENAI_BASE_URL is set and may affect connectivity.\n - OPENAI_BASE_URL = https://example.com/v1".to_vec() + ); + assert_eq!(attachments_with_diagnostics[2].buffer, b"rollout".to_vec()); + assert_eq!( + OsStr::new(attachments_with_diagnostics[2].filename.as_str()), + OsStr::new(extra_filename.as_str()) + ); + let attachments_without_diagnostics = CodexFeedback::new() + .snapshot(None) + .feedback_attachments(true, &[], Some(vec![1])); + + assert_eq!( + attachments_without_diagnostics + .iter() + .map(|attachment| attachment.filename.as_str()) + .collect::>(), + vec!["codex-logs.log"] + ); + assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); + fs::remove_file(extra_path).expect("extra attachment should be removed"); + } } diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 33368faa45..f9bbfe2436 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,6 +1,8 @@ use std::cell::RefCell; use std::path::PathBuf; +use codex_feedback::feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; +use codex_feedback::feedback_diagnostics::FeedbackDiagnostics; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -19,6 +21,8 @@ use crate::app_event::FeedbackCategory; use crate::app_event_sender::AppEventSender; use crate::history_cell; use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; use codex_protocol::protocol::SessionSource; use super::CancellationEvent; @@ -46,7 +50,7 @@ pub(crate) enum FeedbackAudience { /// both logs and rollout with classification + metadata. pub(crate) struct FeedbackNoteView { category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, + snapshot: codex_feedback::FeedbackSnapshot, rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -61,7 +65,7 @@ pub(crate) struct FeedbackNoteView { impl FeedbackNoteView { pub(crate) fn new( category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, + snapshot: codex_feedback::FeedbackSnapshot, rollout_path: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -87,7 +91,7 @@ impl FeedbackNoteView { } else { Some(note.as_str()) }; - let log_file_paths = if self.include_logs { + let attachment_paths = if self.include_logs { self.rollout_path.iter().cloned().collect::>() } else { Vec::new() @@ -100,7 +104,7 @@ impl FeedbackNoteView { classification, reason_opt, self.include_logs, - &log_file_paths, + &attachment_paths, Some(SessionSource::Cli), None, ); @@ -217,21 +221,21 @@ impl BottomPaneView for FeedbackNoteView { impl Renderable for FeedbackNoteView { fn desired_height(&self, width: u16) -> u16 { - 1u16 + self.input_height(width) + 3u16 + self.intro_lines(width).len() as u16 + self.input_height(width) + 2u16 } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.height < 2 || area.width <= 2 { return None; } + let intro_height = self.intro_lines(area.width).len() as u16; let text_area_height = self.input_height(area.width).saturating_sub(1); if text_area_height == 0 { return None; } - let top_line_count = 1u16; // title only let textarea_rect = Rect { x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), + y: area.y.saturating_add(intro_height).saturating_add(1), width: area.width.saturating_sub(2), height: text_area_height, }; @@ -244,23 +248,26 @@ impl Renderable for FeedbackNoteView { return; } - let (title, placeholder) = feedback_title_and_placeholder(self.category); + let intro_lines = self.intro_lines(area.width); + let (_, placeholder) = feedback_title_and_placeholder(self.category); let input_height = self.input_height(area.width); - // Title line - let title_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }; - let title_spans: Vec> = vec![gutter(), title.bold()]; - Paragraph::new(Line::from(title_spans)).render(title_area, buf); + for (offset, line) in intro_lines.iter().enumerate() { + Paragraph::new(line.clone()).render( + Rect { + x: area.x, + y: area.y.saturating_add(offset as u16), + width: area.width, + height: 1, + }, + buf, + ); + } // Input line let input_area = Rect { x: area.x, - y: area.y.saturating_add(1), + y: area.y.saturating_add(intro_lines.len() as u16), width: area.width, height: input_height, }; @@ -334,6 +341,56 @@ impl FeedbackNoteView { let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); text_height.saturating_add(1).min(9) } + + fn intro_lines(&self, width: u16) -> Vec> { + let (title, _) = feedback_title_and_placeholder(self.category); + let mut lines = vec![Line::from(vec![gutter(), title.bold()])]; + if should_show_feedback_connectivity_details( + self.category, + self.snapshot.feedback_diagnostics(), + ) { + lines.push(Line::from(vec![gutter()])); + lines.push(Line::from(vec![ + gutter(), + "Connectivity diagnostics".bold(), + ])); + lines.extend(self.diagnostics_lines(width)); + } + lines + } + + fn diagnostics_lines(&self, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + let headline_options = RtOptions::new(width) + .initial_indent(Line::from(vec![gutter(), " - ".into()])) + .subsequent_indent(Line::from(vec![gutter(), " ".into()])); + let detail_options = RtOptions::new(width) + .initial_indent(Line::from(vec![gutter(), " - ".dim()])) + .subsequent_indent(Line::from(vec![gutter(), " ".into()])); + let mut lines = Vec::new(); + + for diagnostic in self.snapshot.feedback_diagnostics().diagnostics() { + lines.extend(word_wrap_lines( + [Line::from(diagnostic.headline.clone())], + headline_options.clone(), + )); + for detail in &diagnostic.details { + lines.extend(word_wrap_lines( + [Line::from(detail.clone())], + detail_options.clone(), + )); + } + } + + lines + } +} + +pub(crate) fn should_show_feedback_connectivity_details( + category: FeedbackCategory, + diagnostics: &FeedbackDiagnostics, +) -> bool { + category != FeedbackCategory::GoodResult && !diagnostics.is_empty() } fn gutter() -> Span<'static> { @@ -485,6 +542,7 @@ pub(crate) fn feedback_upload_consent_params( app_event_tx: AppEventSender, category: FeedbackCategory, rollout_path: Option, + include_connectivity_diagnostics_attachment: bool, ) -> super::SelectionViewParams { use super::popup_consts::standard_popup_hint_line; let yes_action: super::SelectionAction = Box::new({ @@ -521,6 +579,15 @@ pub(crate) fn feedback_upload_consent_params( { header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); } + if include_connectivity_diagnostics_attachment { + header_lines.push( + Line::from(vec![ + " • ".into(), + FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.into(), + ]) + .into(), + ); + } super::SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), @@ -537,7 +604,6 @@ pub(crate) fn feedback_upload_consent_params( }, super::SelectionItem { name: "No".to_string(), - description: Some("".to_string()), actions: vec![no_action], dismiss_on_select: true, ..Default::default() @@ -555,6 +621,8 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; + use codex_feedback::feedback_diagnostics::FeedbackDiagnostic; + use pretty_assertions::assert_eq; fn render(view: &FeedbackNoteView, width: u16) -> String { let height = view.desired_height(width); @@ -635,6 +703,62 @@ mod tests { insta::assert_snapshot!("feedback_view_safety_check", rendered); } + #[test] + fn feedback_view_with_connectivity_diagnostics() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let diagnostics = FeedbackDiagnostics::new(vec![ + FeedbackDiagnostic { + headline: "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()], + }, + FeedbackDiagnostic { + headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(), + details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()], + }, + ]); + let snapshot = codex_feedback::CodexFeedback::new() + .snapshot(None) + .with_feedback_diagnostics(diagnostics); + let view = FeedbackNoteView::new( + FeedbackCategory::Bug, + snapshot, + None, + tx, + false, + FeedbackAudience::External, + ); + let rendered = render(&view, 60); + + insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); + } + + #[test] + fn should_show_feedback_connectivity_details_only_for_non_good_result_with_diagnostics() { + let diagnostics = FeedbackDiagnostics::new(vec![FeedbackDiagnostic { + headline: "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()], + }]); + + assert_eq!( + should_show_feedback_connectivity_details(FeedbackCategory::Bug, &diagnostics), + true + ); + assert_eq!( + should_show_feedback_connectivity_details(FeedbackCategory::GoodResult, &diagnostics), + false + ); + assert_eq!( + should_show_feedback_connectivity_details( + FeedbackCategory::BadResult, + &FeedbackDiagnostics::default() + ), + false + ); + } + #[test] fn issue_url_available_for_bug_bad_result_safety_check_and_other() { let bug_url = issue_url_for_category( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_with_connectivity_diagnostics.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_with_connectivity_diagnostics.snap new file mode 100644 index 0000000000..ec1322ce84 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_with_connectivity_diagnostics.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 749 +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ Connectivity diagnostics +▌ - Proxy environment variables are set and may affect +▌ connectivity. +▌ - HTTP_PROXY = http://proxy.example.com:8080 +▌ - OPENAI_BASE_URL is set and may affect connectivity. +▌ - OPENAI_BASE_URL = https://example.com/v1 +▌ +▌ (optional) Write a short description to help us further + +Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a0d3454183..0296bd08d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1284,8 +1284,16 @@ impl ChatWidget { category: crate::app_event::FeedbackCategory, include_logs: bool, ) { - // Build a fresh snapshot at the time of opening the note overlay. let snapshot = self.feedback.snapshot(self.thread_id); + self.show_feedback_note(category, include_logs, snapshot); + } + + fn show_feedback_note( + &mut self, + category: crate::app_event::FeedbackCategory, + include_logs: bool, + snapshot: codex_feedback::FeedbackSnapshot, + ) { let rollout = if include_logs { self.current_rollout_path.clone() } else { @@ -1310,10 +1318,14 @@ impl ChatWidget { } pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { + let snapshot = self.feedback.snapshot(self.thread_id); let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), + snapshot + .feedback_diagnostics_attachment_text(true) + .is_some(), ); self.bottom_pane.show_selection_view(params); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap new file mode 100644 index 0000000000..4529d6d478 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + • codex-connectivity-diagnostics.txt + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index cc3d8e3755..4529d6d478 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -6,6 +6,7 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-connectivity-diagnostics.txt › 1. Yes Share the current Codex session logs with the team for troubleshooting. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6ea109dacb..0ee7974d26 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7068,13 +7068,32 @@ async fn feedback_selection_popup_snapshot() { async fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Open the consent popup directly for a chosen category. - chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); + chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( + chat.app_event_tx.clone(), + crate::app_event::FeedbackCategory::Bug, + chat.current_rollout_path.clone(), + true, + )); let popup = render_bottom_popup(&chat, 80); assert_snapshot!("feedback_upload_consent_popup", popup); } +#[tokio::test] +async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_filename() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( + chat.app_event_tx.clone(), + crate::app_event::FeedbackCategory::GoodResult, + chat.current_rollout_path.clone(), + true, + )); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("feedback_good_result_consent_popup", popup); +} + #[tokio::test] async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; From 0fbd84081bf11065dfd0cd8d9f31faa6b599877a Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 3 Mar 2026 17:01:00 -0800 Subject: [PATCH 08/23] feat(app-server): add a skills/changed v2 notification (#13414) This adds a first-class app-server v2 `skills/changed` notification for the existing skills live-reload signal. Before this change, clients only had the legacy raw `codex/event/skills_update_available` event. With this PR, v2 clients can listen for a typed JSON-RPC notification instead of depending on the legacy `codex/event/*` stream, which we want to remove soon. --- .../schema/json/ServerNotification.json | 24 ++++++++ .../codex_app_server_protocol.schemas.json | 26 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 26 ++++++++ .../json/v2/SkillsChangedNotification.json | 6 ++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/SkillsChangedNotification.ts | 11 ++++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 9 +++ codex-rs/app-server/README.md | 9 +++ .../app-server/src/bespoke_event_handling.rs | 10 +++ .../app-server/tests/suite/v2/skills_list.rs | 61 +++++++++++++++++++ 12 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 28df2574b2..61d15286a1 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1485,6 +1485,10 @@ } ] }, + "SkillsChangedNotification": { + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "type": "object" + }, "SubAgentSource": { "oneOf": [ { @@ -3202,6 +3206,26 @@ "title": "Thread/closedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "skills/changed" + ], + "title": "Skills/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Skills/changedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a60e6c4a44..61e32239f7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6039,6 +6039,26 @@ "title": "Thread/closedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "skills/changed" + ], + "title": "Skills/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Skills/changedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12302,6 +12322,12 @@ ], "type": "object" }, + "SkillsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" + }, "SkillsConfigWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b4a5f9f6cb..1d4482c6ff 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9977,6 +9977,26 @@ "title": "Thread/closedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "skills/changed" + ], + "title": "Skills/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Skills/changedNotification", + "type": "object" + }, { "properties": { "method": { @@ -10972,6 +10992,12 @@ ], "type": "object" }, + "SkillsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" + }, "SkillsConfigWriteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json new file mode 100644 index 0000000000..cb67d81602 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsChangedNotification.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "title": "SkillsChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index d7f70d3558..8157ba2f48 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -25,6 +25,7 @@ import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummar import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification"; import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNotification"; import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification"; +import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; @@ -48,4 +49,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts new file mode 100644 index 0000000000..23ed93a5ec --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsChangedNotification.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Notification emitted when watched local skill files change. + * + * Treat this as an invalidation signal and re-run `skills/list` with the + * client's current parameters when refreshed skill metadata is needed. + */ +export type SkillsChangedNotification = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 9c677641fa..02bf51727d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -150,6 +150,7 @@ export type { SkillInterface } from "./SkillInterface"; export type { SkillMetadata } from "./SkillMetadata"; export type { SkillScope } from "./SkillScope"; export type { SkillToolDependency } from "./SkillToolDependency"; +export type { SkillsChangedNotification } from "./SkillsChangedNotification"; export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams"; export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 67968507c7..19e1fb1770 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -753,6 +753,7 @@ server_notification_definitions! { ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification), ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification), ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), + SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6b26433ae0..0a63b42c46 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3635,6 +3635,15 @@ pub struct ThreadClosedNotification { pub thread_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification emitted when watched local skill files change. +/// +/// Treat this as an invalidation signal and re-run `skills/list` with the +/// client's current parameters when refreshed skill metadata is needed. +pub struct SkillsChangedNotification {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d2d8aa5445..786a935b6d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -148,6 +148,7 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `skills/changed` — notification emitted when watched local skill files change. - `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). - `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. @@ -841,6 +842,7 @@ Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, wi You can also add `perCwdExtraUserRoots` to scan additional absolute paths as `user` scope for specific `cwd` entries. Entries whose `cwd` is not present in `cwds` are ignored. `skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. +The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. ```json { "method": "skills/list", "id": 25, "params": { @@ -876,6 +878,13 @@ Entries whose `cwd` is not present in `cwds` are ignored. } } ``` +```json +{ + "method": "skills/changed", + "params": {} +} +``` + To enable or disable a skill by path: ```json diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2f293c9347..4d005706db 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -61,6 +61,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadNameUpdatedNotification; @@ -220,6 +221,15 @@ pub(crate) async fn apply_bespoke_event_handling( .await; handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; } + EventMsg::SkillsUpdateAvailable => { + if let ApiVersion::V2 = api_version { + outgoing + .send_server_notification(ServerNotification::SkillsChanged( + SkillsChangedNotification {}, + )) + .await; + } + } EventMsg::Warning(_warning_event) => {} EventMsg::ModelReroute(event) => { if let ApiVersion::V2 = api_version { diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index e099bdb646..03443a47eb 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -1,18 +1,22 @@ use std::time::Duration; +use anyhow::Context; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadStartParams; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const WATCHER_TIMEOUT: Duration = Duration::from_secs(20); fn write_skill(root: &TempDir, name: &str) -> Result<()> { let skill_dir = root.path().join("skills").join(name); @@ -214,3 +218,60 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { ); Ok(()) } + +#[tokio::test] +async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { + let codex_home = TempDir::new()?; + write_skill(&codex_home, "demo")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: None, + model_provider: None, + service_tier: None, + cwd: None, + approval_policy: None, + sandbox: None, + config: None, + service_name: None, + base_instructions: None, + developer_instructions: None, + personality: None, + ephemeral: None, + dynamic_tools: None, + mock_experimental_field: None, + experimental_raw_events: false, + persist_extended_history: false, + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + + let skill_path = codex_home + .path() + .join("skills") + .join("demo") + .join("SKILL.md"); + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: updated\n---\n\n# Updated\n", + )?; + + let notification = timeout( + WATCHER_TIMEOUT, + mcp.read_stream_until_notification_message("skills/changed"), + ) + .await??; + let params = notification + .params + .context("skills/changed params must be present")?; + let notification: SkillsChangedNotification = serde_json::from_value(params)?; + + assert_eq!(notification, SkillsChangedNotification {}); + Ok(()) +} From 52521a5e408c86b22dfe37edab7cec2376854961 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 3 Mar 2026 17:03:45 -0800 Subject: [PATCH 09/23] feat(app-server): propagate app-server trace context into core (#13368) ### Summary Propagate trace context originating at app-server RPC method handlers -> codex core submission loop (so this includes spans such as `run_turn`!). This implements PR 2 of the app-server tracing rollout. This also removes the old lower-level env-based reparenting in core so explicit request/submission ancestry wins instead of being overridden by ambient `TRACEPARENT` state. ### What changed - Added `trace: Option` to codex_protocol::Submission - Taught `Codex::submit()` / `submit_with_id()` to automatically capture the current span context when constructing or forwarding a submission - Wrapped the core submission loop in a submission_dispatch span parented from Submission.trace - Warn on invalid submission trace carriers and ignore them cleanly - Removed the old env-based downstream reparenting path in core task execution - Stopped OTEL provider init from implicitly attaching env trace context process-wide - Updated mcp-server Submission call sites for the new field Added focused unit tests for: - capturing trace context into Submission - preferring `Submission.trace` when building the core dispatch span ### Why PR 1 gave us consistent inbound request spans in app-server, but that only covered the transport boundary. For long-running work like turns and reviews, the important missing piece was preserving ancestry after the request handler returns and core continues work on a different async path. This change makes that handoff explicit and keeps the parentage rules simple: - app-server request span sets the current context - `Submission.trace` snapshots that context - core restores it once, at the submission boundary - deeper core spans inherit naturally That also lets us stop relying on env-based reparenting for this path, which was too ambient and could override explicit ancestry. --- codex-rs/Cargo.lock | 6 + codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/codex.rs | 475 ++++++++++++------- codex-rs/core/src/codex_delegate.rs | 50 +- codex-rs/core/src/tasks/regular.rs | 3 - codex-rs/exec/Cargo.toml | 4 + codex-rs/exec/src/lib.rs | 110 ++++- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/otel/src/otel_provider.rs | 22 - codex-rs/otel/src/traces/otel_manager.rs | 8 - codex-rs/protocol/src/protocol.rs | 3 + 12 files changed, 489 insertions(+), 196 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48e8c598db..1974883dd4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1851,6 +1851,7 @@ dependencies = [ "notify", "once_cell", "openssl-sys", + "opentelemetry", "opentelemetry_sdk", "os_info", "predicates", @@ -1880,6 +1881,7 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "toml_edit 0.24.0+spec-1.1.0", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "tracing-test", "url", @@ -1916,6 +1918,7 @@ dependencies = [ "codex-arg0", "codex-cloud-requirements", "codex-core", + "codex-otel", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1925,6 +1928,8 @@ dependencies = [ "codex-utils-sandbox-summary", "core_test_support", "libc", + "opentelemetry", + "opentelemetry_sdk", "owo-colors", "predicates", "pretty_assertions", @@ -1936,6 +1941,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-opentelemetry", "tracing-subscriber", "ts-rs", "uuid", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index eafedc9d73..ebb01edc4e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -161,6 +161,7 @@ core_test_support = { workspace = true } ctor = { workspace = true } insta = { workspace = true } maplit = { workspace = true } +opentelemetry = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } test-case = "3.3.1" @@ -170,6 +171,7 @@ opentelemetry_sdk = { workspace = true, features = [ ] } serial_test = { workspace = true } tempfile = { workspace = true } +tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } tracing-test = { workspace = true, features = ["no-env-filter"] } walkdir = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1ead1871f5..87aac73185 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -70,6 +70,8 @@ use codex_hooks::HooksConfig; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyAuditMetadata; use codex_network_proxy::normalize_host; +use codex_otel::current_span_w3c_trace_context; +use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; @@ -539,14 +541,21 @@ impl Codex { /// Submit the `op` wrapped in a `Submission` with a unique ID. pub async fn submit(&self, op: Op) -> CodexResult { let id = Uuid::now_v7().to_string(); - let sub = Submission { id: id.clone(), op }; + let sub = Submission { + id: id.clone(), + op, + trace: None, + }; self.submit_with_id(sub).await?; Ok(id) } /// Use sparingly: prefer `submit()` so Codex is responsible for generating /// unique IDs for each submission. - pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { + pub async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> { + if sub.trace.is_none() { + sub.trace = current_span_w3c_trace_context(); + } self.tx_sub .send(sub) .await @@ -3686,176 +3695,230 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); - match sub.op.clone() { - Op::Interrupt => { - handlers::interrupt(&sess).await; - } - Op::CleanBackgroundTerminals => { - handlers::clean_background_terminals(&sess).await; - } - Op::RealtimeConversationStart(params) => { - if let Err(err) = - handle_realtime_conversation_start(&sess, sub.id.clone(), params).await - { - sess.send_event_raw(Event { - id: sub.id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) - .await; + let dispatch_span = submission_dispatch_span(&sub); + let should_exit = async { + match sub.op.clone() { + Op::Interrupt => { + handlers::interrupt(&sess).await; + false } - } - Op::RealtimeConversationAudio(params) => { - handle_realtime_conversation_audio(&sess, sub.id.clone(), params).await; - } - Op::RealtimeConversationText(params) => { - handle_realtime_conversation_text(&sess, sub.id.clone(), params).await; - } - Op::RealtimeConversationClose => { - handle_realtime_conversation_close(&sess, sub.id.clone()).await; - } - Op::OverrideTurnContext { - cwd, - approval_policy, - sandbox_policy, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => { - let collaboration_mode = if let Some(collab_mode) = collaboration_mode { - collab_mode - } else { - let state = sess.state.lock().await; - state.session_configuration.collaboration_mode.with_updates( - model.clone(), - effort, - None, + Op::CleanBackgroundTerminals => { + handlers::clean_background_terminals(&sess).await; + false + } + Op::RealtimeConversationStart(params) => { + if let Err(err) = + handle_realtime_conversation_start(&sess, sub.id.clone(), params).await + { + sess.send_event_raw(Event { + id: sub.id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + } + false + } + Op::RealtimeConversationAudio(params) => { + handle_realtime_conversation_audio(&sess, sub.id.clone(), params).await; + false + } + Op::RealtimeConversationText(params) => { + handle_realtime_conversation_text(&sess, sub.id.clone(), params).await; + false + } + Op::RealtimeConversationClose => { + handle_realtime_conversation_close(&sess, sub.id.clone()).await; + false + } + Op::OverrideTurnContext { + cwd, + approval_policy, + sandbox_policy, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } => { + let collaboration_mode = if let Some(collab_mode) = collaboration_mode { + collab_mode + } else { + let state = sess.state.lock().await; + state.session_configuration.collaboration_mode.with_updates( + model.clone(), + effort, + None, + ) + }; + handlers::override_turn_context( + &sess, + sub.id.clone(), + SessionSettingsUpdate { + cwd, + approval_policy, + sandbox_policy, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + }, ) - }; - handlers::override_turn_context( - &sess, - sub.id.clone(), - SessionSettingsUpdate { - cwd, - approval_policy, - sandbox_policy, - windows_sandbox_level, - collaboration_mode: Some(collaboration_mode), - reasoning_summary: summary, - service_tier, - personality, - ..Default::default() - }, - ) - .await; - } - Op::UserInput { .. } | Op::UserTurn { .. } => { - handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; - } - Op::ExecApproval { - id: approval_id, - turn_id, - decision, - } => { - handlers::exec_approval(&sess, approval_id, turn_id, decision).await; - } - Op::PatchApproval { id, decision } => { - handlers::patch_approval(&sess, id, decision).await; - } - Op::UserInputAnswer { id, response } => { - handlers::request_user_input_response(&sess, id, response).await; - } - Op::DynamicToolResponse { id, response } => { - handlers::dynamic_tool_response(&sess, id, response).await; - } - Op::AddToHistory { text } => { - handlers::add_to_history(&sess, &config, text).await; - } - Op::GetHistoryEntryRequest { offset, log_id } => { - handlers::get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id) .await; - } - Op::ListMcpTools => { - handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await; - } - Op::RefreshMcpServers { config } => { - handlers::refresh_mcp_servers(&sess, config).await; - } - Op::ReloadUserConfig => { - handlers::reload_user_config(&sess).await; - } - Op::ListCustomPrompts => { - handlers::list_custom_prompts(&sess, sub.id.clone()).await; - } - Op::ListSkills { cwds, force_reload } => { - handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; - } - Op::ListRemoteSkills { - hazelnut_scope, - product_surface, - enabled, - } => { - handlers::list_remote_skills( - &sess, - &config, - sub.id.clone(), + false + } + Op::UserInput { .. } | Op::UserTurn { .. } => { + handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; + false + } + Op::ExecApproval { + id: approval_id, + turn_id, + decision, + } => { + handlers::exec_approval(&sess, approval_id, turn_id, decision).await; + false + } + Op::PatchApproval { id, decision } => { + handlers::patch_approval(&sess, id, decision).await; + false + } + Op::UserInputAnswer { id, response } => { + handlers::request_user_input_response(&sess, id, response).await; + false + } + Op::DynamicToolResponse { id, response } => { + handlers::dynamic_tool_response(&sess, id, response).await; + false + } + Op::AddToHistory { text } => { + handlers::add_to_history(&sess, &config, text).await; + false + } + Op::GetHistoryEntryRequest { offset, log_id } => { + handlers::get_history_entry_request( + &sess, + &config, + sub.id.clone(), + offset, + log_id, + ) + .await; + false + } + Op::ListMcpTools => { + handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await; + false + } + Op::RefreshMcpServers { config } => { + handlers::refresh_mcp_servers(&sess, config).await; + false + } + Op::ReloadUserConfig => { + handlers::reload_user_config(&sess).await; + false + } + Op::ListCustomPrompts => { + handlers::list_custom_prompts(&sess, sub.id.clone()).await; + false + } + Op::ListSkills { cwds, force_reload } => { + handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; + false + } + Op::ListRemoteSkills { hazelnut_scope, product_surface, enabled, - ) - .await; - } - Op::DownloadRemoteSkill { hazelnut_id } => { - handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id).await; - } - Op::Undo => { - handlers::undo(&sess, sub.id.clone()).await; - } - Op::Compact => { - handlers::compact(&sess, sub.id.clone()).await; - } - Op::DropMemories => { - handlers::drop_memories(&sess, &config, sub.id.clone()).await; - } - Op::UpdateMemories => { - handlers::update_memories(&sess, &config, sub.id.clone()).await; - } - Op::ThreadRollback { num_turns } => { - handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; - } - Op::SetThreadName { name } => { - handlers::set_thread_name(&sess, sub.id.clone(), name).await; - } - Op::RunUserShellCommand { command } => { - handlers::run_user_shell_command(&sess, sub.id.clone(), command).await; - } - Op::ResolveElicitation { - server_name, - request_id, - decision, - } => { - handlers::resolve_elicitation(&sess, server_name, request_id, decision).await; - } - Op::Shutdown => { - if handlers::shutdown(&sess, sub.id.clone()).await { - break; + } => { + handlers::list_remote_skills( + &sess, + &config, + sub.id.clone(), + hazelnut_scope, + product_surface, + enabled, + ) + .await; + false } + Op::DownloadRemoteSkill { hazelnut_id } => { + handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id) + .await; + false + } + Op::Undo => { + handlers::undo(&sess, sub.id.clone()).await; + false + } + Op::Compact => { + handlers::compact(&sess, sub.id.clone()).await; + false + } + Op::DropMemories => { + handlers::drop_memories(&sess, &config, sub.id.clone()).await; + false + } + Op::UpdateMemories => { + handlers::update_memories(&sess, &config, sub.id.clone()).await; + false + } + Op::ThreadRollback { num_turns } => { + handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; + false + } + Op::SetThreadName { name } => { + handlers::set_thread_name(&sess, sub.id.clone(), name).await; + false + } + Op::RunUserShellCommand { command } => { + handlers::run_user_shell_command(&sess, sub.id.clone(), command).await; + false + } + Op::ResolveElicitation { + server_name, + request_id, + decision, + } => { + handlers::resolve_elicitation(&sess, server_name, request_id, decision).await; + false + } + Op::Shutdown => handlers::shutdown(&sess, sub.id.clone()).await, + Op::Review { review_request } => { + handlers::review(&sess, &config, sub.id.clone(), review_request).await; + false + } + _ => false, // Ignore unknown ops; enum is non_exhaustive to allow extensions. } - Op::Review { review_request } => { - handlers::review(&sess, &config, sub.id.clone(), review_request).await; - } - _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. + } + .instrument(dispatch_span) + .await; + if should_exit { + break; } } debug!("Agent loop exited"); } +fn submission_dispatch_span(sub: &Submission) -> tracing::Span { + let dispatch_span = info_span!("submission_dispatch", submission.id = sub.id.as_str()); + if let Some(trace) = sub.trace.as_ref() + && !set_parent_from_w3c_trace_context(&dispatch_span, trace) + { + warn!( + submission.id = sub.id.as_str(), + "ignoring invalid submission trace carrier" + ); + } + dispatch_span +} + /// Operation handlers mod handlers { use crate::codex::Session; @@ -6627,9 +6690,17 @@ mod tests { use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; + use codex_protocol::protocol::Submission; + use codex_protocol::protocol::W3cTraceContext; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TracerProvider as _; + use opentelemetry_sdk::trace::SdkTracerProvider; use std::path::Path; use std::time::Duration; use tokio::time::sleep; + use tracing_opentelemetry::OpenTelemetrySpanExt; + use tracing_subscriber::prelude::*; use codex_protocol::mcp::CallToolResult as McpCallToolResult; use pretty_assertions::assert_eq; @@ -8105,6 +8176,12 @@ mod tests { }) } + fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync { + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("codex-core-tests"); + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)) + } + async fn build_test_config(codex_home: &Path) -> Config { ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) @@ -8432,6 +8509,86 @@ mod tests { (session, turn_context) } + #[tokio::test] + async fn submit_with_id_captures_current_span_trace_context() { + let (session, _turn_context) = make_session_and_context().await; + let (tx_sub, rx_sub) = async_channel::bounded(1); + let (_tx_event, rx_event) = async_channel::unbounded(); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Codex { + tx_sub, + rx_event, + agent_status, + session: Arc::new(session), + }; + + let subscriber = test_tracing_subscriber(); + let _guard = tracing::subscriber::set_default(subscriber); + + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + + let expected_trace = async { + let expected_trace = + current_span_w3c_trace_context().expect("current span should have trace context"); + codex + .submit_with_id(Submission { + id: "sub-1".into(), + op: Op::Interrupt, + trace: None, + }) + .await + .expect("submit should succeed"); + expected_trace + } + .instrument(request_span) + .await; + + let submitted = rx_sub.recv().await.expect("submission"); + assert_eq!(submitted.trace, Some(expected_trace)); + } + + #[test] + fn submission_dispatch_span_prefers_submission_trace_context() { + let subscriber = test_tracing_subscriber(); + let _guard = tracing::subscriber::set_default(subscriber); + + let ambient_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000033-0000000000000044-01".into()), + tracestate: None, + }; + let ambient_span = info_span!("ambient"); + assert!(set_parent_from_w3c_trace_context( + &ambient_span, + &ambient_parent + )); + + let submission_trace = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000055-0000000000000066-01".into()), + tracestate: Some("vendor=value".into()), + }; + let dispatch_span = ambient_span.in_scope(|| { + submission_dispatch_span(&Submission { + id: "sub-1".into(), + op: Op::Interrupt, + trace: Some(submission_trace), + }) + }); + + let trace_id = dispatch_span.context().span().span_context().trace_id(); + assert_eq!( + trace_id, + TraceId::from_hex("00000000000000000000000000000055").expect("trace id") + ); + } + pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( dynamic_tools: Vec, ) -> ( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1e8dd71419..f6ef7f36e0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -154,6 +154,7 @@ pub(crate) async fn run_codex_thread_one_shot( .send(Submission { id: "shutdown".to_string(), op: Op::Shutdown {}, + trace: None, }) .await; child_cancel.cancel(); @@ -298,11 +299,11 @@ async fn forward_ops( cancel_token_ops: CancellationToken, ) { loop { - let op: Op = match rx_ops.recv().or_cancel(&cancel_token_ops).await { - Ok(Ok(Submission { id: _, op })) => op, + let submission = match rx_ops.recv().or_cancel(&cancel_token_ops).await { + Ok(Ok(submission)) => submission, Ok(Err(_)) | Err(_) => break, }; - let _ = codex.submit(op).await; + let _ = codex.submit_with_id(submission).await; } } @@ -550,4 +551,47 @@ mod tests { "expected Shutdown op after cancellation" ); } + + #[tokio::test] + async fn forward_ops_preserves_submission_trace_context() { + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let (session, _ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await; + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events, + agent_status, + session, + }); + let (tx_ops, rx_ops) = bounded(1); + let cancel = CancellationToken::new(); + let forward = tokio::spawn(forward_ops(Arc::clone(&codex), rx_ops, cancel)); + + let submission = Submission { + id: "sub-1".to_string(), + op: Op::Interrupt, + trace: Some(codex_protocol::protocol::W3cTraceContext { + traceparent: Some( + "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), + ), + tracestate: Some("vendor=state".to_string()), + }), + }; + tx_ops.send(submission.clone()).await.unwrap(); + drop(tx_ops); + + let forwarded = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("forward_ops hung") + .expect("forwarded submission missing"); + assert_eq!(submission.id, forwarded.id); + assert_eq!(submission.op, forwarded.op); + assert_eq!(submission.trace, forwarded.trace); + + timeout(Duration::from_secs(1), forward) + .await + .expect("forward_ops did not exit") + .expect("forward_ops join error"); + } } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 62493a46cd..b4af4e11f3 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -78,9 +78,6 @@ impl SessionTask for RegularTask { let sess = session.clone_session(); let run_turn_span = trace_span!("run_turn"); sess.set_server_reasoning_included(false).await; - sess.services - .otel_manager - .apply_traceparent_parent(&run_turn_span); let prewarmed_client_session = self.take_prewarmed_session().await; run_turn( sess, diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index f3fa573ab5..1eb4f7e277 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -21,6 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } @@ -55,10 +56,13 @@ codex-apply-patch = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } libc = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true } tempfile = { workspace = true } +tracing-opentelemetry = { workspace = true } uuid = { workspace = true } walkdir = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5f0ac4865e..0c7271c56f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -34,6 +34,8 @@ use codex_core::format_exec_policy_error_with_source; use codex_core::git_info::get_git_repo_root; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::models_manager::manager::RefreshStrategy; +use codex_otel::set_parent_from_context; +use codex_otel::traceparent_context_from_env; use codex_protocol::approvals::ElicitationAction; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; @@ -58,9 +60,12 @@ use std::path::PathBuf; use std::sync::Arc; use supports_color::Stream; use tokio::sync::Mutex; +use tracing::Instrument; use tracing::debug; use tracing::error; +use tracing::field; use tracing::info; +use tracing::info_span; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -94,6 +99,32 @@ struct ThreadEventEnvelope { suppress_output: bool, } +struct ExecRunArgs { + command: Option, + config: Config, + cursor_ansi: bool, + dangerously_bypass_approvals_and_sandbox: bool, + exec_span: tracing::Span, + images: Vec, + json_mode: bool, + last_message_file: Option, + model_provider: Option, + oss: bool, + output_schema_path: Option, + prompt: Option, + skip_git_repo_check: bool, + stderr_with_ansi: bool, +} + +fn exec_root_span() -> tracing::Span { + info_span!( + "codex.exec", + otel.kind = "internal", + thread.id = field::Empty, + turn.id = field::Empty, + ) +} + pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { if let Err(err) = set_default_originator("codex_exec".to_string()) { tracing::warn!(?err, "Failed to set codex exec originator override {err:?}"); @@ -347,6 +378,48 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .with(otel_logger_layer) .try_init(); + let exec_span = exec_root_span(); + if let Some(context) = traceparent_context_from_env() { + set_parent_from_context(&exec_span, context); + } + run_exec_session(ExecRunArgs { + command, + config, + cursor_ansi, + dangerously_bypass_approvals_and_sandbox, + exec_span: exec_span.clone(), + images, + json_mode, + last_message_file, + model_provider, + oss, + output_schema_path, + prompt, + skip_git_repo_check, + stderr_with_ansi, + }) + .instrument(exec_span) + .await +} + +async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { + let ExecRunArgs { + command, + config, + cursor_ansi, + dangerously_bypass_approvals_and_sandbox, + exec_span, + images, + json_mode, + last_message_file, + model_provider, + oss, + output_schema_path, + prompt, + skip_git_repo_check, + stderr_with_ansi, + } = args; + let mut event_processor: Box = match json_mode { true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( @@ -435,6 +508,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } else { thread_manager.start_thread(config.clone()).await? }; + let primary_thread_id_for_span = primary_thread_id.to_string(); + exec_span.record("thread.id", primary_thread_id_for_span.as_str()); + let (initial_operation, prompt_summary) = match (command, prompt, images) { (Some(ExecCommand::Review(review_cli)), _, _) => { let review_request = build_review_request(review_cli)?; @@ -554,7 +630,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result }); } - match initial_operation { + let task_id = match initial_operation { InitialOperation::UserTurn { items, output_schema, @@ -583,6 +659,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result task_id } }; + exec_span.record("turn.id", task_id.as_str()); // Run the loop until the task is complete. // Track whether a fatal error was reported by the server so we can @@ -934,13 +1011,44 @@ fn build_review_request(args: ReviewArgs) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_otel::set_parent_from_w3c_trace_context; + use opentelemetry::trace::TraceContextExt; + use opentelemetry::trace::TraceId; + use opentelemetry::trace::TracerProvider as _; + use opentelemetry_sdk::trace::SdkTracerProvider; use pretty_assertions::assert_eq; + use tracing_opentelemetry::OpenTelemetrySpanExt; + + fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync { + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("codex-exec-tests"); + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)) + } #[test] fn exec_defaults_analytics_to_enabled() { assert_eq!(DEFAULT_ANALYTICS_ENABLED, true); } + #[test] + fn exec_root_span_can_be_parented_from_trace_context() { + let subscriber = test_tracing_subscriber(); + let _guard = tracing::subscriber::set_default(subscriber); + + let parent = codex_protocol::protocol::W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()), + tracestate: Some("vendor=value".into()), + }; + let exec_span = exec_root_span(); + assert!(set_parent_from_w3c_trace_context(&exec_span, &parent)); + + let trace_id = exec_span.context().span().span_context().trace_id(); + assert_eq!( + trace_id, + TraceId::from_hex("00000000000000000000000000000077").expect("trace id") + ); + } + #[test] fn builds_uncommitted_review_request() { let request = build_review_request(ReviewArgs { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index e624fe2b1b..24896b042a 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -115,6 +115,7 @@ pub async fn run_codex_tool_session( }], final_output_json_schema: None, }, + trace: None, }; if let Err(e) = thread.submit_with_id(submission).await { diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6b634e1947..e51c3d619d 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -576,6 +576,7 @@ impl MessageProcessor { .submit_with_id(Submission { id: request_id_string, op: codex_protocol::protocol::Op::Interrupt, + trace: None, }) .await { diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index c9233f5d6a..dcfb313b67 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -7,7 +7,6 @@ use crate::trace_context::context_from_trace_headers; use gethostname::gethostname; use opentelemetry::Context; use opentelemetry::KeyValue; -use opentelemetry::context::ContextGuard; use opentelemetry::global; use opentelemetry::trace::TracerProvider as _; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; @@ -28,7 +27,6 @@ use opentelemetry_sdk::trace::BatchSpanProcessor; use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_sdk::trace::Tracer; use opentelemetry_semantic_conventions as semconv; -use std::cell::RefCell; use std::env; use std::error::Error; use std::sync::OnceLock; @@ -43,10 +41,6 @@ const HOST_NAME_ATTRIBUTE: &str = "host.name"; const TRACEPARENT_ENV_VAR: &str = "TRACEPARENT"; const TRACESTATE_ENV_VAR: &str = "TRACESTATE"; static TRACEPARENT_CONTEXT: OnceLock> = OnceLock::new(); - -thread_local! { - static TRACEPARENT_GUARD: RefCell> = const { RefCell::new(None) }; -} pub struct OtelProvider { pub logger: Option, pub tracer_provider: Option, @@ -113,10 +107,6 @@ impl OtelProvider { global::set_tracer_provider(provider); global::set_text_map_propagator(TraceContextPropagator::new()); } - if tracer.is_some() { - attach_traceparent_context(); - } - Ok(Some(Self { logger, tracer_provider, @@ -176,18 +166,6 @@ pub fn traceparent_context_from_env() -> Option { .clone() } -fn attach_traceparent_context() { - TRACEPARENT_GUARD.with(|guard| { - let mut guard = guard.borrow_mut(); - if guard.is_some() { - return; - } - if let Some(context) = traceparent_context_from_env() { - *guard = Some(context.attach()); - } - }); -} - fn load_traceparent_context() -> Option { let traceparent = env::var(TRACEPARENT_ENV_VAR).ok()?; let tracestate = env::var(TRACESTATE_ENV_VAR).ok(); diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 32b9fba860..1074b6edd1 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -15,7 +15,6 @@ use crate::metrics::names::WEBSOCKET_EVENT_COUNT_METRIC; use crate::metrics::names::WEBSOCKET_EVENT_DURATION_METRIC; use crate::metrics::names::WEBSOCKET_REQUEST_COUNT_METRIC; use crate::metrics::names::WEBSOCKET_REQUEST_DURATION_METRIC; -use crate::otel_provider::traceparent_context_from_env; use crate::sanitize_metric_tag_value; use chrono::SecondsFormat; use chrono::Utc; @@ -41,7 +40,6 @@ use std::time::Duration; use std::time::Instant; use tokio::time::error::Elapsed; use tracing::Span; -use tracing_opentelemetry::OpenTelemetrySpanExt; pub use crate::OtelEventMetadata; pub use crate::OtelManager; @@ -92,12 +90,6 @@ impl OtelManager { } } - pub fn apply_traceparent_parent(&self, span: &Span) { - if let Some(context) = traceparent_context_from_env() { - let _ = span.set_parent(context); - } - } - pub fn record_responses(&self, handle_responses_span: &Span, event: &ResponseEvent) { handle_responses_span.record("otel.name", OtelManager::responses_type(event)); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3f1c392719..2591a8fb1d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -82,6 +82,9 @@ pub struct Submission { pub id: String, /// Payload pub op: Op, + /// Optional W3C trace carrier propagated across async submission handoffs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] From 639a5f6c48a22fa0b828064518fadcafe8ad31e4 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 3 Mar 2026 17:31:37 -0800 Subject: [PATCH 10/23] copy command-runner to CODEX_HOME so sandbox users can always execute it (#13413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Keep Windows sandbox runner launches working from packaged installs by running the helper from a user-owned runtime location. On some Windows installs, the packaged helper location is difficult to use reliably for sandboxed runner launches even though the binaries are present. This change works around that by copying codex- command-runner.exe into CODEX_HOME/.sandbox-bin/, reusing that copy across launches, and falling back to the existing packaged-path lookup if anything goes wrong. The runtime copy lives in a dedicated directory with tighter ACLs than .sandbox: sandbox users can read and execute the runner there, but they cannot modify it. This keeps the workaround focused on the command runner, leaves the setup helper on its trusted packaged path, and adds logging so it is clear which runner path was selected at launch. --- codex-rs/windows-sandbox-rs/Cargo.toml | 2 +- .../windows-sandbox-rs/src/elevated_impl.rs | 36 +- .../src/helper_materialization.rs | 351 ++++++++++++++++++ codex-rs/windows-sandbox-rs/src/identity.rs | 2 +- codex-rs/windows-sandbox-rs/src/lib.rs | 3 + .../windows-sandbox-rs/src/setup_main_win.rs | 34 +- .../src/setup_orchestrator.rs | 45 ++- 7 files changed, 448 insertions(+), 25 deletions(-) create mode 100644 codex-rs/windows-sandbox-rs/src/helper_materialization.rs diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index f92c389e4b..cb7cebe9d7 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -29,6 +29,7 @@ codex-utils-string = { workspace = true } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +tempfile = "3" windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", @@ -79,7 +80,6 @@ version = "0.52" [dev-dependencies] pretty_assertions = { workspace = true } -tempfile = "3" [build-dependencies] winres = "0.1" diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 8f8de37e0e..b3a5e9a1aa 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -6,6 +6,8 @@ mod windows_impl { use crate::env::ensure_non_interactive_pager; use crate::env::inherit_path_env; use crate::env::normalize_null_device_env; + use crate::helper_materialization::resolve_helper_for_launch; + use crate::helper_materialization::HelperExecutable; use crate::identity::require_logon_sandbox_creds; use crate::logging::log_failure; use crate::logging::log_note; @@ -110,17 +112,9 @@ mod windows_impl { } } - /// Locates `codex-command-runner.exe` next to the current binary. - fn find_runner_exe() -> PathBuf { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let candidate = dir.join("codex-command-runner.exe"); - if candidate.exists() { - return candidate; - } - } - } - PathBuf::from("codex-command-runner.exe") + /// Resolves the command runner path, preferring CODEX_HOME/.sandbox/bin. + fn find_runner_exe(codex_home: &Path, log_dir: Option<&Path>) -> PathBuf { + resolve_helper_for_launch(HelperExecutable::CommandRunner, codex_home, log_dir) } /// Generates a unique named-pipe path used to communicate with the runner process. @@ -286,7 +280,7 @@ mod windows_impl { )?; // Launch runner as sandbox user via CreateProcessWithLogonW. - let runner_exe = find_runner_exe(); + let runner_exe = find_runner_exe(codex_home, logs_base_dir); let runner_cmdline = runner_exe .to_str() .map(|s| s.to_string()) @@ -340,6 +334,16 @@ mod windows_impl { // Suppress WER/UI popups from the runner process so we can collect exit codes. let _ = unsafe { SetErrorMode(0x0001 | 0x0002) }; // SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX + log_note( + &format!( + "runner launch: exe={} cmdline={} cwd={}", + runner_exe.display(), + runner_full_cmd, + cwd.display() + ), + logs_base_dir, + ); + // Ensure command line buffer is mutable and includes the exe as argv[0]. let spawn_res = unsafe { CreateProcessWithLogonW( @@ -362,6 +366,14 @@ mod windows_impl { }; if spawn_res == 0 { let err = unsafe { GetLastError() } as i32; + log_note( + &format!( + "runner launch failed before process start: exe={} cmdline={} error={err}", + runner_exe.display(), + runner_full_cmd + ), + logs_base_dir, + ); return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {}", err)); } diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs new file mode 100644 index 0000000000..cda0800628 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -0,0 +1,351 @@ +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::OnceLock; +use tempfile::NamedTempFile; + +use crate::logging::log_note; +use crate::sandbox_bin_dir; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum HelperExecutable { + CommandRunner, +} + +impl HelperExecutable { + fn file_name(self) -> &'static str { + match self { + Self::CommandRunner => "codex-command-runner.exe", + } + } + + fn label(self) -> &'static str { + match self { + Self::CommandRunner => "command-runner", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CopyOutcome { + Reused, + ReCopied, +} + +static HELPER_PATH_CACHE: OnceLock>> = OnceLock::new(); + +pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf { + sandbox_bin_dir(codex_home) +} + +pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join(kind.file_name()); + if candidate.exists() { + return candidate; + } + } + } + PathBuf::from(kind.file_name()) +} + +pub(crate) fn resolve_helper_for_launch( + kind: HelperExecutable, + codex_home: &Path, + log_dir: Option<&Path>, +) -> PathBuf { + match copy_helper_if_needed(kind, codex_home, log_dir) { + Ok(path) => { + log_note( + &format!( + "helper launch resolution: using copied {} path {}", + kind.label(), + path.display() + ), + log_dir, + ); + path + } + Err(err) => { + let fallback = legacy_lookup(kind); + log_note( + &format!( + "helper copy failed for {}: {err:#}; falling back to legacy path {}", + kind.label(), + fallback.display() + ), + log_dir, + ); + fallback + } + } +} + +pub(crate) fn copy_helper_if_needed( + kind: HelperExecutable, + codex_home: &Path, + log_dir: Option<&Path>, +) -> Result { + let cache_key = format!("{}|{}", kind.file_name(), codex_home.display()); + if let Some(path) = cached_helper_path(&cache_key) { + log_note( + &format!( + "helper copy: using in-memory cache for {} -> {}", + kind.label(), + path.display() + ), + log_dir, + ); + return Ok(path); + } + + let source = sibling_source_path(kind)?; + let destination = helper_bin_dir(codex_home).join(kind.file_name()); + log_note( + &format!( + "helper copy: validating {} source={} destination={}", + kind.label(), + source.display(), + destination.display() + ), + log_dir, + ); + let outcome = copy_from_source_if_needed(&source, &destination)?; + let action = match outcome { + CopyOutcome::Reused => "reused", + CopyOutcome::ReCopied => "recopied", + }; + log_note( + &format!( + "helper copy: {} {} source={} destination={}", + action, + kind.label(), + source.display(), + destination.display() + ), + log_dir, + ); + store_helper_path(cache_key, destination.clone()); + Ok(destination) +} + +fn cached_helper_path(cache_key: &str) -> Option { + let cache = HELPER_PATH_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let guard = cache.lock().ok()?; + guard.get(cache_key).cloned() +} + +fn store_helper_path(cache_key: String, path: PathBuf) { + let cache = HELPER_PATH_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut guard) = cache.lock() { + guard.insert(cache_key, path); + } +} + +fn sibling_source_path(kind: HelperExecutable) -> Result { + let exe = std::env::current_exe().context("resolve current executable for helper lookup")?; + let dir = exe + .parent() + .ok_or_else(|| anyhow!("current executable has no parent directory"))?; + let candidate = dir.join(kind.file_name()); + if candidate.exists() { + Ok(candidate) + } else { + Err(anyhow!( + "helper not found next to current executable: {}", + candidate.display() + )) + } +} + +fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result { + if destination_is_fresh(source, destination)? { + return Ok(CopyOutcome::Reused); + } + + let destination_dir = destination + .parent() + .ok_or_else(|| anyhow!("helper destination has no parent: {}", destination.display()))?; + fs::create_dir_all(destination_dir).with_context(|| { + format!( + "create helper destination directory {}", + destination_dir.display() + ) + })?; + + let temp_path = NamedTempFile::new_in(destination_dir) + .with_context(|| { + format!( + "create temporary helper file in {}", + destination_dir.display() + ) + })? + .into_temp_path(); + let temp_path_buf = temp_path.to_path_buf(); + + let mut source_file = fs::File::open(source) + .with_context(|| format!("open helper source for read {}", source.display()))?; + let mut temp_file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&temp_path_buf) + .with_context(|| format!("open temporary helper file {}", temp_path_buf.display()))?; + + // Write into a temp file created inside `.sandbox-bin` so the copied helper keeps the + // destination directory's inherited ACLs instead of reusing the source file's descriptor. + std::io::copy(&mut source_file, &mut temp_file).with_context(|| { + format!( + "copy helper from {} to {}", + source.display(), + temp_path_buf.display() + ) + })?; + temp_file + .flush() + .with_context(|| format!("flush temporary helper file {}", temp_path_buf.display()))?; + drop(temp_file); + + if destination.exists() { + fs::remove_file(destination).with_context(|| { + format!("remove stale helper destination {}", destination.display()) + })?; + } + + match fs::rename(&temp_path_buf, destination) { + Ok(()) => Ok(CopyOutcome::ReCopied), + Err(rename_err) => { + if destination_is_fresh(source, destination)? { + Ok(CopyOutcome::Reused) + } else { + Err(rename_err).with_context(|| { + format!( + "rename helper temp file {} to {}", + temp_path_buf.display(), + destination.display() + ) + }) + } + } + } +} + +fn destination_is_fresh(source: &Path, destination: &Path) -> Result { + let source_meta = fs::metadata(source) + .with_context(|| format!("read helper source metadata {}", source.display()))?; + let destination_meta = match fs::metadata(destination) { + Ok(meta) => meta, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => { + return Err(err) + .with_context(|| format!("read helper destination metadata {}", destination.display())); + } + }; + + if source_meta.len() != destination_meta.len() { + return Ok(false); + } + + let source_modified = source_meta + .modified() + .with_context(|| format!("read helper source mtime {}", source.display()))?; + let destination_modified = destination_meta + .modified() + .with_context(|| format!("read helper destination mtime {}", destination.display()))?; + + Ok(destination_modified >= source_modified) +} + +#[cfg(test)] +mod tests { + use super::destination_is_fresh; + use super::helper_bin_dir; + use super::copy_from_source_if_needed; + use super::CopyOutcome; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use std::path::PathBuf; + use tempfile::TempDir; + + #[test] + fn copy_from_source_if_needed_copies_missing_destination() { + let tmp = TempDir::new().expect("tempdir"); + let source = tmp.path().join("source.exe"); + let destination = tmp.path().join("bin").join("helper.exe"); + + fs::write(&source, b"runner-v1").expect("write source"); + + let outcome = copy_from_source_if_needed(&source, &destination).expect("copy helper"); + + assert_eq!(CopyOutcome::ReCopied, outcome); + assert_eq!(b"runner-v1".as_slice(), fs::read(&destination).expect("read destination")); + } + + #[test] + fn destination_is_fresh_uses_size_and_mtime() { + let tmp = TempDir::new().expect("tempdir"); + let source = tmp.path().join("source.exe"); + let destination = tmp.path().join("destination.exe"); + + fs::write(&destination, b"same-size").expect("write destination"); + std::thread::sleep(std::time::Duration::from_secs(1)); + fs::write(&source, b"same-size").expect("write source"); + assert!(!destination_is_fresh(&source, &destination).expect("stale metadata")); + + fs::write(&destination, b"same-size").expect("rewrite destination"); + assert!(destination_is_fresh(&source, &destination).expect("fresh metadata")); + } + + #[test] + fn copy_from_source_if_needed_reuses_fresh_destination() { + let tmp = TempDir::new().expect("tempdir"); + let source = tmp.path().join("source.exe"); + let destination = tmp.path().join("bin").join("helper.exe"); + + fs::write(&source, b"runner-v1").expect("write source"); + copy_from_source_if_needed(&source, &destination).expect("initial copy"); + + let outcome = + copy_from_source_if_needed(&source, &destination).expect("revalidate helper"); + + assert_eq!(CopyOutcome::Reused, outcome); + assert_eq!(b"runner-v1".as_slice(), fs::read(&destination).expect("read destination")); + } + + #[test] + fn helper_bin_dir_is_under_sandbox_bin() { + let codex_home = Path::new(r"C:\Users\example\.codex"); + + assert_eq!( + PathBuf::from(r"C:\Users\example\.codex\.sandbox-bin"), + helper_bin_dir(codex_home) + ); + } + + #[test] + fn copy_runner_into_shared_bin_dir() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let source_dir = tmp.path().join("sibling-source"); + fs::create_dir_all(&source_dir).expect("create source dir"); + let runner_source = source_dir.join("codex-command-runner.exe"); + let runner_destination = helper_bin_dir(&codex_home).join("codex-command-runner.exe"); + fs::write(&runner_source, b"runner").expect("runner"); + + let runner_outcome = + copy_from_source_if_needed(&runner_source, &runner_destination).expect("runner copy"); + + assert_eq!(CopyOutcome::ReCopied, runner_outcome); + assert_eq!( + b"runner".as_slice(), + fs::read(&runner_destination).expect("read runner") + ); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index b195e36b91..b9d5e29ed7 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -130,7 +130,7 @@ pub fn require_logon_sandbox_creds( codex_home: &Path, ) -> Result { let sandbox_dir = crate::setup::sandbox_dir(codex_home); - let needed_read = gather_read_roots(command_cwd, policy); + let needed_read = gather_read_roots(command_cwd, policy, codex_home); let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map); // NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the // restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index fc96e5186b..565d589724 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -11,6 +11,7 @@ windows_modules!( cap, dpapi, env, + helper_materialization, hide_users, identity, logging, @@ -85,6 +86,8 @@ pub use setup::run_setup_refresh; #[cfg(target_os = "windows")] pub use setup::run_setup_refresh_with_extra_read_roots; #[cfg(target_os = "windows")] +pub use setup::sandbox_bin_dir; +#[cfg(target_os = "windows")] pub use setup::sandbox_dir; #[cfg(target_os = "windows")] pub use setup::sandbox_secrets_dir; diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index f3de7c8564..476620cc2e 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -18,6 +18,7 @@ use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::protect_workspace_agents_dir; use codex_windows_sandbox::protect_workspace_codex_dir; +use codex_windows_sandbox::sandbox_bin_dir; use codex_windows_sandbox::sandbox_dir; use codex_windows_sandbox::sandbox_secrets_dir; use codex_windows_sandbox::string_from_sid_bytes; @@ -245,6 +246,8 @@ fn lock_sandbox_dir( real_user: &str, sandbox_group_sid: &[u8], sandbox_group_access_mode: i32, + sandbox_group_mask: u32, + real_user_mask: u32, _log: &mut File, ) -> Result<()> { std::fs::create_dir_all(dir)?; @@ -254,7 +257,7 @@ fn lock_sandbox_dir( let entries = [ ( sandbox_group_sid.to_vec(), - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + sandbox_group_mask, sandbox_group_access_mode, ), ( @@ -267,11 +270,7 @@ fn lock_sandbox_dir( FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, GRANT_ACCESS, ), - ( - real_sid, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, - GRANT_ACCESS, - ), + (real_sid, real_user_mask, GRANT_ACCESS), ]; unsafe { let mut eas: Vec = Vec::new(); @@ -740,6 +739,25 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( } }); + lock_sandbox_dir( + &sandbox_bin_dir(&payload.codex_home), + &payload.real_user, + &sandbox_group_sid, + GRANT_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + log, + ) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSandboxLockFailed, + format!( + "lock sandbox bin dir {} failed: {err}", + sandbox_bin_dir(&payload.codex_home).display() + ), + )) + })?; + if refresh_only { log_line( log, @@ -756,6 +774,8 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( &payload.real_user, &sandbox_group_sid, GRANT_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, log, ) .map_err(|err| { @@ -772,6 +792,8 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( &payload.real_user, &sandbox_group_sid, DENY_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, log, ) .map_err(|err| { diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index d612ea6583..d43d8b85fc 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -11,6 +11,7 @@ use std::process::Stdio; use crate::allow::compute_allow_paths; use crate::allow::AllowDenyPaths; +use crate::helper_materialization::helper_bin_dir; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; @@ -55,6 +56,10 @@ pub fn sandbox_dir(codex_home: &Path) -> PathBuf { codex_home.join(".sandbox") } +pub fn sandbox_bin_dir(codex_home: &Path) -> PathBuf { + codex_home.join(".sandbox-bin") +} + pub fn sandbox_secrets_dir(codex_home: &Path) -> PathBuf { codex_home.join(".sandbox-secrets") } @@ -93,7 +98,7 @@ pub fn run_setup_refresh_with_extra_read_roots( codex_home: &Path, extra_read_roots: Vec, ) -> Result<()> { - let mut read_roots = gather_read_roots(command_cwd, policy); + let mut read_roots = gather_read_roots(command_cwd, policy, codex_home); read_roots.extend(extra_read_roots); run_setup_refresh_inner( policy, @@ -276,13 +281,20 @@ fn profile_read_roots(user_profile: &Path) -> Vec { .collect() } -pub(crate) fn gather_read_roots(command_cwd: &Path, policy: &SandboxPolicy) -> Vec { +pub(crate) fn gather_read_roots( + command_cwd: &Path, + policy: &SandboxPolicy, + codex_home: &Path, +) -> Vec { let mut roots: Vec = Vec::new(); if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { roots.push(dir.to_path_buf()); } } + let helper_dir = helper_bin_dir(codex_home); + let _ = std::fs::create_dir_all(&helper_dir); + roots.push(helper_dir); for p in [ PathBuf::from(r"C:\Windows"), PathBuf::from(r"C:\Program Files"), @@ -583,7 +595,7 @@ fn build_payload_roots( let mut read_roots = if let Some(roots) = read_roots_override { canonical_existing(&roots) } else { - gather_read_roots(command_cwd, policy) + gather_read_roots(command_cwd, policy, codex_home) }; let write_root_set: HashSet = write_roots.iter().cloned().collect(); read_roots.retain(|root| !write_root_set.contains(root)); @@ -591,11 +603,14 @@ fn build_payload_roots( } fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> Vec { - // Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox. - // These locations contain sandbox control/state and must remain tamper-resistant. + // Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox, + // CODEX_HOME/.sandbox-bin, or CODEX_HOME/.sandbox-secrets. These locations contain sandbox + // control/state and helper binaries and must remain tamper-resistant. let codex_home_key = canonical_path_key(codex_home); let sbx_dir_key = canonical_path_key(&sandbox_dir(codex_home)); let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/')); + let sbx_bin_dir_key = canonical_path_key(&sandbox_bin_dir(codex_home)); + let sbx_bin_dir_prefix = format!("{}/", sbx_bin_dir_key.trim_end_matches('/')); let secrets_dir_key = canonical_path_key(&sandbox_secrets_dir(codex_home)); let secrets_dir_prefix = format!("{}/", secrets_dir_key.trim_end_matches('/')); @@ -604,6 +619,8 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V key != codex_home_key && key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix) + && key != sbx_bin_dir_key + && !key.starts_with(&sbx_bin_dir_prefix) && key != secrets_dir_key && !key.starts_with(&secrets_dir_prefix) }); @@ -612,7 +629,10 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V #[cfg(test)] mod tests { + use super::gather_read_roots; use super::profile_read_roots; + use crate::helper_materialization::helper_bin_dir; + use crate::policy::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; @@ -649,4 +669,19 @@ mod tests { assert_eq!(vec![missing_profile], roots); } + + #[test] + fn gather_read_roots_includes_helper_bin_dir() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let command_cwd = tmp.path().join("workspace"); + fs::create_dir_all(&command_cwd).expect("create workspace"); + let policy = SandboxPolicy::new_read_only_policy(); + + let roots = gather_read_roots(&command_cwd, &policy, &codex_home); + let expected = + dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir"); + + assert!(roots.contains(&expected)); + } } From 2d8c1575b8ba94179923348065062dc70f09deab Mon Sep 17 00:00:00 2001 From: zbarsky-openai Date: Tue, 3 Mar 2026 20:59:32 -0500 Subject: [PATCH 11/23] [bazel] Bump rules_rs and llvm (#13366) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .bazelrc | 2 +- BUILD.bazel | 9 +-- MODULE.bazel | 72 +++++-------------- MODULE.bazel.lock | 27 ++++--- codex-rs/app-server/BUILD.bazel | 1 - codex-rs/core/BUILD.bazel | 1 - codex-rs/mcp-server/BUILD.bazel | 1 - defs.bzl | 31 +++----- ...hains_llvm_bootstrapped_resource_dir.patch | 37 +++------- rbe.bzl | 2 +- 10 files changed, 57 insertions(+), 126 deletions(-) diff --git a/.bazelrc b/.bazelrc index 7f561aa9ee..820a94a735 100644 --- a/.bazelrc +++ b/.bazelrc @@ -18,7 +18,7 @@ common --enable_platform_specific_config common:linux --host_platform=//:local_linux common:windows --host_platform=//:local_windows common --@rules_cc//cc/toolchains/args/archiver_flags:use_libtool_on_macos=False -common --@toolchains_llvm_bootstrapped//config:experimental_stub_libgcc_s +common --@llvm//config:experimental_stub_libgcc_s # We need to use the sh toolchain on windows so we don't send host bash paths to the linux executor. common:windows --@rules_rust//rust/settings:experimental_use_sh_toolchain_for_bootstrap_process_wrapper diff --git a/BUILD.bazel b/BUILD.bazel index 66548cd03c..ed38c18b57 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,11 +1,4 @@ load("@apple_support//xcode:xcode_config.bzl", "xcode_config") -load("@rules_cc//cc:defs.bzl", "cc_shared_library") - -cc_shared_library( - name = "clang", - deps = ["@llvm-project//clang:libclang"], - visibility = ["//visibility:public"], -) xcode_config(name = "disable_xcode") @@ -16,7 +9,7 @@ platform( name = "local_linux", constraint_values = [ # We mark the local platform as glibc-compatible because musl-built rust cannot dlopen proc macros. - "@toolchains_llvm_bootstrapped//constraints/libc:gnu.2.28", + "@llvm//constraints/libc:gnu.2.28", ], parents = ["@platforms//host"], ) diff --git a/MODULE.bazel b/MODULE.bazel index b2115f4664..f55bd1f26d 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,18 +1,18 @@ module(name = "codex") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.6") +bazel_dep(name = "llvm", version = "0.6.1") single_version_override( - module_name = "toolchains_llvm_bootstrapped", + module_name = "llvm", patch_strip = 1, patches = [ "//patches:toolchains_llvm_bootstrapped_resource_dir.patch", ], ) -register_toolchains("@toolchains_llvm_bootstrapped//toolchain:all") +register_toolchains("@llvm//toolchain:all") -osx = use_extension("@toolchains_llvm_bootstrapped//extensions:osx.bzl", "osx") +osx = use_extension("@llvm//extensions:osx.bzl", "osx") osx.framework(name = "ApplicationServices") osx.framework(name = "AppKit") osx.framework(name = "ColorSync") @@ -33,21 +33,13 @@ osx.framework(name = "Kernel") osx.framework(name = "OSLog") osx.framework(name = "Security") osx.framework(name = "SystemConfiguration") -use_repo(osx, "macosx15.4.sdk") +use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") bazel_dep(name = "rules_platform", version = "0.1.0") -bazel_dep(name = "rules_rs", version = "0.0.23") - -# Special toolchains branch -archive_override( - module_name = "rules_rs", - integrity = "sha256-O34UF4H7b1Qacu3vlu2Od4ILGVApzg5j1zl952SFL3w=", - strip_prefix = "rules_rs-097123c2aa72672e371e69e7035869f5a45c7b2b", - url = "https://github.com/dzbarsky/rules_rs/archive/097123c2aa72672e371e69e7035869f5a45c7b2b.tar.gz", -) +bazel_dep(name = "rules_rs", version = "0.0.40") rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust") use_repo(rules_rust, "rules_rust") @@ -57,13 +49,9 @@ toolchains.toolchain( edition = "2024", version = "1.93.0", ) -use_repo( - toolchains, - "experimental_rust_toolchains_1_93_0", - "rust_toolchain_artifacts_macos_aarch64_1_93_0", -) +use_repo(toolchains, "default_rust_toolchains") -register_toolchains("@experimental_rust_toolchains_1_93_0//:all") +register_toolchains("@default_rust_toolchains//:all") crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") crate.from_cargo( @@ -79,6 +67,7 @@ crate.from_cargo( "x86_64-apple-darwin", "x86_64-pc-windows-gnullvm", ], + use_experimental_platforms = True, ) bazel_dep(name = "zstd", version = "1.5.7") @@ -139,11 +128,9 @@ crate.annotation( "OPENSSL_NO_VENDOR": "1", "OPENSSL_STATIC": "1", }, - crate_features = [ - "dep:openssl-src", - ], crate = "openssl-sys", data = ["@openssl//:gen_dir"], + gen_build_script = "on", ) inject_repo(crate, "openssl") @@ -153,27 +140,28 @@ crate.annotation( workspace_cargo_toml = "rust/runfiles/Cargo.toml", ) -llvm = use_extension("@toolchains_llvm_bootstrapped//extensions:llvm.bzl", "llvm") +llvm = use_extension("@llvm//extensions:llvm.bzl", "llvm") use_repo(llvm, "llvm-project") crate.annotation( # Provide the hermetic SDK path so the build script doesn't try to invoke an unhermetic `xcrun --show-sdk-path`. build_script_data = [ - "@macosx15.4.sdk//sysroot", + "@macos_sdk//sysroot", ], build_script_env = { - "BINDGEN_EXTRA_CLANG_ARGS": "-isystem $(location @toolchains_llvm_bootstrapped//:builtin_headers)", - "COREAUDIO_SDK_PATH": "$(location @macosx15.4.sdk//sysroot)", - "LIBCLANG_PATH": "$(location @codex//:clang)", + "BINDGEN_EXTRA_CLANG_ARGS": "-isystem $(location @llvm//:builtin_headers)", + "COREAUDIO_SDK_PATH": "$(location @macos_sdk//sysroot)", + "LIBCLANG_PATH": "$(location @llvm-project//clang:libclang_interface_output)", }, build_script_tools = [ - "@codex//:clang", - "@toolchains_llvm_bootstrapped//:builtin_headers", + "@llvm-project//clang:libclang_interface_output", + "@llvm//:builtin_headers", ], crate = "coreaudio-sys", + gen_build_script = "on", ) -inject_repo(crate, "codex", "toolchains_llvm_bootstrapped", "macosx15.4.sdk") +inject_repo(crate, "llvm", "llvm-project", "macos_sdk") # Fix readme inclusions crate.annotation( @@ -184,28 +172,6 @@ crate.annotation( ], ) -WINDOWS_IMPORT_LIB = """ -load("@rules_cc//cc:defs.bzl", "cc_import") - -cc_import( - name = "windows_import_lib", - static_library = glob(["lib/*.a"])[0], -) -""" - -crate.annotation( - additive_build_file_content = WINDOWS_IMPORT_LIB, - crate = "windows_x86_64_gnullvm", - gen_build_script = "off", - deps = [":windows_import_lib"], -) -crate.annotation( - additive_build_file_content = WINDOWS_IMPORT_LIB, - crate = "windows_aarch64_gnullvm", - gen_build_script = "off", - deps = [":windows_import_lib"], -) - bazel_dep(name = "alsa_lib", version = "1.2.9.bcr.4") crate.annotation( diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 9b459b9941..46220ab3cb 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -44,10 +44,12 @@ "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", - "https://bcr.bazel.build/modules/bazel_features/1.34.0/source.json": "dfa5c4b01110313153b484a735764d247fee5624bbab63d25289e43b151a657a", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", + "https://bcr.bazel.build/modules/bazel_features/1.42.0/source.json": "f2ea90e5dd0322481147114c7d5e4608c4b3fae2eeccae655e4d76a382389f6f", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", @@ -88,6 +90,9 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/MODULE.bazel": "7c034d7a4d92b2293294934377f5d1cbc88119710a11079fa8142120f6f08768", "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/llvm/0.6.0/MODULE.bazel": "42c2182c49f13d2df83a4a4a95ab55d31efda47b2d67acf419bf6b31522b2a30", + "https://bcr.bazel.build/modules/llvm/0.6.1/MODULE.bazel": "29170ab19f4e2dc9b6bbf9b3d101738e84142f63ba29a13cc33e0d40f74c79b0", + "https://bcr.bazel.build/modules/llvm/0.6.1/source.json": "2d8cdd3a5f8e1d16132dbbe97250133101e4863c0376d23273d9afd7363cc331", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", @@ -199,6 +204,8 @@ "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", + "https://bcr.bazel.build/modules/rules_rs/0.0.40/MODULE.bazel": "63238bcb69010753dbd37b5ed08cb79d3af2d88a40b0fda0b110f60f307e86d4", + "https://bcr.bazel.build/modules/rules_rs/0.0.40/source.json": "ae3b17d2f9e4fbcd3de543318e71f83d8522c8527f385bf2b2a7665ec504827e", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", @@ -223,9 +230,6 @@ "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658", - "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.2/MODULE.bazel": "f7c822cea99caef928d7cbe695498096e53c4b2c0ea45997e9a64bf6b77b43b0", - "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/MODULE.bazel": "7298112608aefec21ea8bfbe325b14472222a0243c3dabcd0287eba418791f35", - "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/source.json": "c2a30627cfe15e4deeba63a75d1894324444ae0276eb17ac0161ba5c220c1cb2", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", @@ -244,7 +248,7 @@ "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { "general": { "bzlTransitiveDigest": "dnnhvKMf9MIXMulhbhHBblZdDAfAkiSVjApIXpUz9Y8=", - "usagesDigest": "RFeOiu3n5PGxbV4G+nHCOyM04AaEegaq6ajL+2iZ3Bo=", + "usagesDigest": "2ScE07TNSr/xo2GnYHCRI4JX4hiql6iZaNKUIUshUv4=", "recordedInputs": [ "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" @@ -255,17 +259,20 @@ "attributes": { "deps": { "abseil-cpp": "20250814.1", - "apple_support": "1.24.2", + "alsa_lib": "1.2.9.bcr.4", + "apple_support": "2.1.0", "aspect_bazel_lib": "2.19.3", "aspect_tools_telemetry": "0.3.2", "bazel_features": "1.34.0", - "bazel_lib": "3.0.0", + "bazel_lib": "3.2.0", "bazel_skylib": "1.8.2", "buildozer": "8.2.1", + "bzip2": "1.0.8.bcr.3", "gawk": "5.3.2.bcr.1", "googletest": "1.17.0", "jq.bzl": "0.1.0", "jsoncpp": "1.9.6", + "libcap": "2.27.bcr.1", "nlohmann_json": "3.6.1", "openssl": "3.5.4.bcr.0", "package_metadata": "0.0.5", @@ -285,15 +292,17 @@ "rules_platform": "0.1.0", "rules_proto": "7.1.0", "rules_python": "1.7.0", - "rules_rs": "0.0.23", "rules_shell": "0.6.1", "rules_swift": "3.1.2", + "sed": "4.9.bcr.3", "stardoc": "0.7.2", "swift_argument_parser": "1.3.1.2", "tar.bzl": "0.6.0", + "toolchains_llvm_bootstrapped": "0.5.6", "with_cfg.bzl": "0.12.0", "yq.bzl": "0.1.1", - "zlib": "1.3.1.bcr.5" + "zlib": "1.3.1.bcr.8", + "zstd": "1.5.7" } } } diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index b6766f2237..5f1ba790d5 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -3,6 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "app-server", crate_name = "codex_app_server", - integration_deps_extra = ["//codex-rs/app-server/tests/common:common"], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index cbfd6cacf1..d0f5b9d8a4 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -46,7 +46,6 @@ codex_rust_crate( # succeeds without this workaround. "//:AGENTS.md", ], - integration_deps_extra = ["//codex-rs/core/tests/common:common"], test_tags = ["no-sandbox"], extra_binaries = [ "//codex-rs/linux-sandbox:codex-linux-sandbox", diff --git a/codex-rs/mcp-server/BUILD.bazel b/codex-rs/mcp-server/BUILD.bazel index abdb61dd49..5bf39611b9 100644 --- a/codex-rs/mcp-server/BUILD.bazel +++ b/codex-rs/mcp-server/BUILD.bazel @@ -3,5 +3,4 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "mcp-server", crate_name = "codex_mcp_server", - integration_deps_extra = ["//codex-rs/mcp-server/tests/common:common"], ) diff --git a/defs.bzl b/defs.bzl index 6be15d80f9..d618725eb8 100644 --- a/defs.bzl +++ b/defs.bzl @@ -17,7 +17,7 @@ def multiplatform_binaries(name, platforms = PLATFORMS): for platform in platforms: platform_data( name = name + "_" + platform, - platform = "@toolchains_llvm_bootstrapped//platforms:" + platform, + platform = "@llvm//platforms:" + platform, target = name, tags = ["manual"], ) @@ -42,7 +42,6 @@ def codex_rust_crate( rustc_flags_extra = [], rustc_env = {}, deps_extra = [], - integration_deps_extra = [], integration_compile_data_extra = [], test_data_extra = [], test_tags = [], @@ -73,7 +72,6 @@ def codex_rust_crate( rustc_env: Extra rustc_env entries to merge with defaults. deps_extra: Extra normal deps beyond @crates resolution. Typically only needed when features add additional deps. - integration_deps_extra: Extra deps for integration tests only. integration_compile_data_extra: Extra compile_data for integration tests. test_data_extra: Extra runtime data for tests. test_tags: Tags applied to unit + integration test targets. @@ -81,11 +79,6 @@ def codex_rust_crate( extra_binaries: Additional binary labels to surface as test data and `CARGO_BIN_EXE_*` environment variables. These are only needed for binaries from a different crate. """ - deps = all_crate_deps(normal = True) + deps_extra - dev_deps = all_crate_deps(normal_dev = True) - proc_macro_deps = all_crate_deps(proc_macro = True) - proc_macro_dev_deps = all_crate_deps(proc_macro_dev = True) - test_env = { "INSTA_WORKSPACE_ROOT": ".", "INSTA_SNAPSHOT_PATH": "src", @@ -99,18 +92,19 @@ def codex_rust_crate( lib_srcs = crate_srcs or native.glob(["src/**/*.rs"], exclude = binaries.values(), allow_empty = True) + maybe_deps = [] + if build_script_enabled and native.glob(["build.rs"], allow_empty = True): cargo_build_script( name = name + "-build-script", srcs = ["build.rs"], deps = all_crate_deps(build = True), - proc_macro_deps = all_crate_deps(build_proc_macro = True), data = build_script_data, # Some build script deps sniff version-related env vars... version = "0.0.0", ) - deps = deps + [name + "-build-script"] + maybe_deps += [name + "-build-script"] if lib_srcs: lib_rule = rust_proc_macro if proc_macro else rust_library @@ -118,8 +112,7 @@ def codex_rust_crate( name = name, crate_name = crate_name, crate_features = crate_features, - deps = deps, - proc_macro_deps = proc_macro_deps, + deps = all_crate_deps() + maybe_deps + deps_extra, compile_data = compile_data, data = lib_data_extra, srcs = lib_srcs, @@ -133,18 +126,14 @@ def codex_rust_crate( name = name + "-unit-tests", crate = name, env = test_env, - deps = deps + dev_deps, - proc_macro_deps = proc_macro_deps + proc_macro_dev_deps, + deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, rustc_flags = rustc_flags_extra, rustc_env = rustc_env, data = test_data_extra, tags = test_tags, ) - if proc_macro: - proc_macro_deps += [name] - else: - deps += [name] + maybe_deps += [name] sanitized_binaries = [] cargo_env = {} @@ -157,8 +146,7 @@ def codex_rust_crate( name = binary, crate_name = binary.replace("-", "_"), crate_root = main, - deps = deps, - proc_macro_deps = proc_macro_deps, + deps = all_crate_deps() + maybe_deps + deps_extra, edition = crate_edition, rustc_flags = rustc_flags_extra, srcs = native.glob(["src/**/*.rs"]), @@ -184,8 +172,7 @@ def codex_rust_crate( srcs = [test], data = native.glob(["tests/**"], allow_empty = True) + sanitized_binaries + test_data_extra, compile_data = native.glob(["tests/**"], allow_empty = True) + integration_compile_data_extra, - deps = deps + dev_deps + integration_deps_extra, - proc_macro_deps = proc_macro_deps + proc_macro_dev_deps, + deps = all_crate_deps(normal = True, normal_dev = True) + maybe_deps + deps_extra, # Keep `file!()` paths Cargo-like (`core/tests/...`) instead of # Bazel workspace-prefixed (`codex-rs/core/tests/...`) for snapshot parity. rustc_flags = rustc_flags_extra + ["--remap-path-prefix=codex-rs="], diff --git a/patches/toolchains_llvm_bootstrapped_resource_dir.patch b/patches/toolchains_llvm_bootstrapped_resource_dir.patch index ab03782802..e475c7a060 100644 --- a/patches/toolchains_llvm_bootstrapped_resource_dir.patch +++ b/patches/toolchains_llvm_bootstrapped_resource_dir.patch @@ -1,5 +1,5 @@ diff --git a/toolchain/args/BUILD.bazel b/toolchain/args/BUILD.bazel -index e6dc56c..b323589 100644 +index 7da0ddb..78326d9 100644 --- a/toolchain/args/BUILD.bazel +++ b/toolchain/args/BUILD.bazel @@ -17,8 +17,8 @@ package(default_visibility = ["//visibility:public"]) @@ -12,29 +12,10 @@ index e6dc56c..b323589 100644 ], args = [ "-resource-dir", -@@ -32,6 +32,46 @@ cc_args( - ], +@@ -32,6 +32,25 @@ cc_args( + }, ) -+alias( -+ name = "clang_builtin_headers_include_search_directory", -+ actual = select({ -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64_gnu": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64_gnu": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_x86_64_musl": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:linux_aarch64_musl": "@llvm-toolchain-minimal-21.1.8-linux-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:macos_x86_64": "@llvm-toolchain-minimal-21.1.8-darwin-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:macos_aarch64": "@llvm-toolchain-minimal-21.1.8-darwin-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:windows_x86_64": "@llvm-toolchain-minimal-21.1.8-windows-amd64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:windows_aarch64": "@llvm-toolchain-minimal-21.1.8-windows-arm64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:none_wasm32": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers", -+ "@toolchains_llvm_bootstrapped//platforms/config:none_wasm64": "@llvm-toolchain-minimal-21.1.8-linux-amd64//:builtin_headers", -+ }), -+ visibility = ["//toolchain:__subpackages__"], -+) -+ +cc_args( + name = "clang_builtin_headers_include_search_paths", + actions = [ @@ -46,24 +27,22 @@ index e6dc56c..b323589 100644 + "{clang_builtin_headers_include_search_path}", + ], + format = { -+ "clang_builtin_headers_include_search_path": ":clang_builtin_headers_include_search_directory", ++ "clang_builtin_headers_include_search_path": "//:builtin_headers", + }, + data = [ -+ ":clang_builtin_headers_include_search_directory", -+ ], -+ allowlist_include_directories = [ -+ ":clang_builtin_headers_include_search_directory", ++ "//:builtin_headers", + ], ++ allowlist_include_directories = ["//:builtin_headers"], +) + cc_args( name = "llvm_target_for_platform", actions = [ diff --git a/toolchain/BUILD.bazel b/toolchain/BUILD.bazel -index 6ffc9f7..e02089a 100644 +index 1f89467..8f20f10 100644 --- a/toolchain/BUILD.bazel +++ b/toolchain/BUILD.bazel -@@ -100,6 +100,7 @@ cc_args_list( +@@ -107,6 +107,7 @@ cc_args_list( "@platforms//os:macos": [], "//conditions:default": [ "//toolchain/args:resource_dir", diff --git a/rbe.bzl b/rbe.bzl index 5d13812771..a634731cad 100644 --- a/rbe.bzl +++ b/rbe.bzl @@ -18,7 +18,7 @@ platform( "@platforms//cpu:{cpu}", "@platforms//os:linux", "@bazel_tools//tools/cpp:clang", - "@toolchains_llvm_bootstrapped//constraints/libc:gnu.2.28", + "@llvm//constraints/libc:gnu.2.28", ], exec_properties = {{ # Ubuntu-based image that includes git, python3, dotslash, and other From e6773f856c97ce766b7f507a99e5447a1e2a306c Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 3 Mar 2026 18:41:57 -0800 Subject: [PATCH 12/23] Feat: Preserve network access on read-only sandbox policies (#13409) ## Summary `PermissionProfile.network` could not be preserved when additional or compiled permissions resolved to `SandboxPolicy::ReadOnly`, because `ReadOnly` had no network_access field. This change makes read-only + network enabled representable directly and threads that through the protocol, app-server v2 mirror, and permission- merging logic. ## What changed - Added `network_access: bool` to `SandboxPolicy::ReadOnly` in the core protocol and app-server v2 protocol. - Kept backward compatibility by defaulting the new field to false, so legacy read-only payloads still deserialize unchanged. - Updated `has_full_network_access()` and sandbox summaries to respect read-only network access. - Preserved PermissionProfile.network when: - compiling skill permission profiles into sandbox policies - normalizing additional permissions - merging additional permissions into existing sandbox policies - Updated the approval overlay to show network in the rendered permission rule when requested. - Regenerated app-server schema fixtures for the new v2 wire shape. --- .../schema/json/ClientRequest.json | 4 + .../schema/json/EventMsg.json | 4 + .../codex_app_server_protocol.schemas.json | 4 + .../codex_app_server_protocol.v2.schemas.json | 4 + .../schema/json/v2/CommandExecParams.json | 4 + .../schema/json/v2/ThreadForkResponse.json | 4 + .../schema/json/v2/ThreadResumeResponse.json | 4 + .../schema/json/v2/ThreadStartResponse.json | 4 + .../schema/json/v2/TurnStartParams.json | 4 + .../schema/typescript/SandboxPolicy.ts | 7 +- .../schema/typescript/v2/SandboxPolicy.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 29 ++++-- codex-rs/app-server-test-client/src/lib.rs | 3 + codex-rs/core/src/sandboxing/mod.rs | 96 +++++++++++++++++-- codex-rs/core/src/skills/permissions.rs | 15 ++- codex-rs/linux-sandbox/src/bwrap.rs | 2 + codex-rs/protocol/src/protocol.rs | 26 ++++- .../tui/src/bottom_pane/approval_overlay.rs | 9 ++ ...overlay_additional_permissions_prompt.snap | 2 +- .../sandbox-summary/src/sandbox_summary.rs | 17 +++- 20 files changed, 218 insertions(+), 26 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 84ee3d5c1f..509d442678 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1637,6 +1637,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 85de9c0dab..a22962cd40 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -5350,6 +5350,10 @@ ], "description": "Read access granted while running under this policy." }, + "network_access": { + "description": "When set to `true`, outbound network access is allowed. `false` by default.", + "type": "boolean" + }, "type": { "enum": [ "read-only" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 61e32239f7..d7ba5671ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11963,6 +11963,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 1d4482c6ff..5cb5a7594c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9743,6 +9743,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 4528b34159..08f1a9a15a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -89,6 +89,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 3e4dca5b1c..41f4c9b7bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -653,6 +653,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index d9fbc0d02c..416bceb6e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -653,6 +653,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index ee8b384998..25db79cf24 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -653,6 +653,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 0a769c3525..9e749d5b81 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -214,6 +214,10 @@ "type": "fullAccess" } }, + "networkAccess": { + "default": false, + "type": "boolean" + }, "type": { "enum": [ "readOnly" diff --git a/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts index 743ad22229..8440fd8043 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SandboxPolicy.ts @@ -12,7 +12,12 @@ export type SandboxPolicy = { "type": "danger-full-access" } | { "type": "read-o /** * Read access granted while running under this policy. */ -access?: ReadOnlyAccess, } | { "type": "external-sandbox", +access?: ReadOnlyAccess, +/** + * When set to `true`, outbound network access is allowed. `false` by + * default. + */ +network_access?: boolean, } | { "type": "external-sandbox", /** * Whether the external sandbox permits outbound network traffic. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index c81c2642d2..c6780648cf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -5,4 +5,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; import type { ReadOnlyAccess } from "./ReadOnlyAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0a63b42c46..8c5dfedb16 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -950,6 +950,8 @@ pub enum SandboxPolicy { ReadOnly { #[serde(default)] access: ReadOnlyAccess, + #[serde(default)] + network_access: bool, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -979,11 +981,13 @@ impl SandboxPolicy { SandboxPolicy::DangerFullAccess => { codex_protocol::protocol::SandboxPolicy::DangerFullAccess } - SandboxPolicy::ReadOnly { access } => { - codex_protocol::protocol::SandboxPolicy::ReadOnly { - access: access.to_core(), - } - } + SandboxPolicy::ReadOnly { + access, + network_access, + } => codex_protocol::protocol::SandboxPolicy::ReadOnly { + access: access.to_core(), + network_access: *network_access, + }, SandboxPolicy::ExternalSandbox { network_access } => { codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -1015,11 +1019,13 @@ impl From for SandboxPolicy { codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess } - codex_protocol::protocol::SandboxPolicy::ReadOnly { access } => { - SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::from(access), - } - } + codex_protocol::protocol::SandboxPolicy::ReadOnly { + access, + network_access, + } => SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::from(access), + network_access, + }, codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -4342,6 +4348,7 @@ mod tests { include_platform_defaults: false, readable_roots: vec![readable_root.clone()], }, + network_access: true, }; let core_policy = v2_policy.to_core(); @@ -4352,6 +4359,7 @@ mod tests { include_platform_defaults: false, readable_roots: vec![readable_root], }, + network_access: true, } ); @@ -4402,6 +4410,7 @@ mod tests { policy, SandboxPolicy::ReadOnly { access: ReadOnlyAccess::FullAccess, + network_access: false, } ); } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 7baad9be66..06f677ceb7 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -589,6 +589,7 @@ fn trigger_zsh_fork_multi_cmd_approval( turn_params.approval_policy = Some(AskForApproval::OnRequest); turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { access: ReadOnlyAccess::FullAccess, + network_access: false, }); let turn_response = client.turn_start(turn_params)?; @@ -722,6 +723,7 @@ fn trigger_cmd_approval( Some(AskForApproval::OnRequest), Some(SandboxPolicy::ReadOnly { access: ReadOnlyAccess::FullAccess, + network_access: false, }), dynamic_tools, ) @@ -744,6 +746,7 @@ fn trigger_patch_approval( Some(AskForApproval::OnRequest), Some(SandboxPolicy::ReadOnly { access: ReadOnlyAccess::FullAccess, + network_access: false, }), dynamic_tools, ) diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index bbd73749e4..fe7d077bb8 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -31,6 +31,7 @@ use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; @@ -110,6 +111,7 @@ pub(crate) fn normalize_additional_permissions( .write .map(|paths| normalize_permission_paths(paths, "file_system.write")); Ok(PermissionProfile { + network: additional_permissions.network, file_system: Some(FileSystemPermissions { read, write }), ..Default::default() }) @@ -123,9 +125,7 @@ fn normalize_permission_paths( let mut seen = HashSet::new(); for path in paths { - let canonicalized = path - .as_path() - .canonicalize() + let canonicalized = canonicalize(path.as_path()) .ok() .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) .unwrap_or(path); @@ -189,6 +189,13 @@ fn merge_read_only_access_with_additional_reads( } } +fn merge_network_access( + base_network_access: bool, + additional_permissions: &PermissionProfile, +) -> bool { + base_network_access || matches!(additional_permissions.network, Some(true)) +} + fn sandbox_policy_with_additional_permissions( sandbox_policy: &SandboxPolicy, additional_permissions: &PermissionProfile, @@ -218,15 +225,19 @@ fn sandbox_policy_with_additional_permissions( read_only_access, extra_reads, ), - network_access: *network_access, + network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, } } - SandboxPolicy::ReadOnly { access } => { + SandboxPolicy::ReadOnly { + access, + network_access, + } => { if extra_writes.is_empty() { SandboxPolicy::ReadOnly { access: merge_read_only_access_with_additional_reads(access, extra_reads), + network_access: merge_network_access(*network_access, additional_permissions), } } else { // todo(dylan) - for now, this grants more access than the request. We should restrict this, @@ -238,7 +249,7 @@ fn sandbox_policy_with_additional_permissions( access, extra_reads, ), - network_access: false, + network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: false, exclude_slash_tmp: false, } @@ -412,11 +423,19 @@ pub async fn execute_env( #[cfg(test)] mod tests { use super::SandboxManager; + use super::normalize_additional_permissions; + use super::sandbox_policy_with_additional_permissions; use crate::exec::SandboxType; + use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::tools::sandboxing::SandboxablePreference; use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::models::FileSystemPermissions; + use codex_protocol::models::PermissionProfile; + use codex_utils_absolute_path::AbsolutePathBuf; + use dunce::canonicalize; use pretty_assertions::assert_eq; + use tempfile::TempDir; #[test] fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { @@ -442,4 +461,69 @@ mod tests { ); assert_eq!(sandbox, expected); } + + #[test] + fn normalize_additional_permissions_preserves_network() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let permissions = normalize_additional_permissions(PermissionProfile { + network: Some(true), + file_system: Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(vec![path.clone()]), + }), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!(permissions.network, Some(true)); + assert_eq!( + permissions.file_system, + Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(vec![path]), + }) + ); + } + + #[test] + fn read_only_additional_permissions_can_enable_network_without_writes() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let policy = sandbox_policy_with_additional_permissions( + &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![path.clone()], + }, + network_access: false, + }, + &PermissionProfile { + network: Some(true), + file_system: Some(FileSystemPermissions { + read: Some(vec![path.clone()]), + write: Some(Vec::new()), + }), + ..Default::default() + }, + ) + .expect("policy"); + + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![path], + }, + network_access: true, + } + ); + } } diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 1d0e6c82ad..825128307d 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -44,6 +44,7 @@ pub(crate) fn compile_permission_profile( file_system, macos, } = permissions?; + let network_access = network.unwrap_or_default(); let file_system = file_system.unwrap_or_default(); let fs_read = normalize_permission_paths( file_system.read.as_deref().unwrap_or_default(), @@ -64,7 +65,7 @@ pub(crate) fn compile_permission_profile( readable_roots: fs_read, } }, - network_access: network.unwrap_or_default(), + network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, } @@ -74,6 +75,12 @@ pub(crate) fn compile_permission_profile( include_platform_defaults: true, readable_roots: fs_read, }, + network_access, + } + } else if network_access { + SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: true, } } else { // Default sandbox policy @@ -320,7 +327,10 @@ mod tests { profile, Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: true, + }), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -366,6 +376,7 @@ mod tests { .expect("absolute read path") ], }, + network_access: true, }), network: None, allow_login_shell: true, diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 67a8b8e604..56c4ce70d1 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -478,6 +478,7 @@ mod tests { .expect("absolute readable root"), ], }, + network_access: false, }; let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); @@ -503,6 +504,7 @@ mod tests { include_platform_defaults: true, readable_roots: Vec::new(), }, + network_access: false, }; // `ReadOnlyAccess::Restricted` always includes `cwd` as a readable diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2591a8fb1d..55f19255b0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -626,6 +626,11 @@ pub enum SandboxPolicy { skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" )] access: ReadOnlyAccess, + + /// When set to `true`, outbound network access is allowed. `false` by + /// default. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + network_access: bool, }, /// Indicates the process is already in an external sandbox. Allows full @@ -715,6 +720,7 @@ impl SandboxPolicy { pub fn new_read_only_policy() -> Self { SandboxPolicy::ReadOnly { access: ReadOnlyAccess::FullAccess, + network_access: false, } } @@ -735,7 +741,7 @@ impl SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ExternalSandbox { .. } => true, - SandboxPolicy::ReadOnly { access } => access.has_full_disk_read_access(), + SandboxPolicy::ReadOnly { access, .. } => access.has_full_disk_read_access(), SandboxPolicy::WorkspaceWrite { read_only_access, .. } => read_only_access.has_full_disk_read_access(), @@ -755,7 +761,7 @@ impl SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => true, SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(), - SandboxPolicy::ReadOnly { .. } => false, + SandboxPolicy::ReadOnly { network_access, .. } => *network_access, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, } } @@ -766,7 +772,7 @@ impl SandboxPolicy { return false; } match self { - SandboxPolicy::ReadOnly { access } => access.include_platform_defaults(), + SandboxPolicy::ReadOnly { access, .. } => access.include_platform_defaults(), SandboxPolicy::WorkspaceWrite { read_only_access, .. } => read_only_access.include_platform_defaults(), @@ -782,7 +788,7 @@ impl SandboxPolicy { pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { let mut roots = match self { SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(), - SandboxPolicy::ReadOnly { access } => access.get_readable_roots_with_cwd(cwd), + SandboxPolicy::ReadOnly { access, .. } => access.get_readable_roots_with_cwd(cwd), SandboxPolicy::WorkspaceWrite { read_only_access, .. } => { @@ -3131,6 +3137,18 @@ mod tests { assert!(enabled.has_full_network_access()); } + #[test] + fn read_only_reports_network_access_flags() { + let restricted = SandboxPolicy::new_read_only_policy(); + assert!(!restricted.has_full_network_access()); + + let enabled = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: true, + }; + assert!(enabled.has_full_network_access()); + } + #[test] fn reject_config_mcp_elicitation_flag_is_field_driven() { assert!( diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index a9ef2cd38a..548be75769 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -641,6 +641,9 @@ fn format_additional_permissions_rule( additional_permissions: &PermissionProfile, ) -> Option { let mut parts = Vec::new(); + if matches!(additional_permissions.network, Some(true)) { + parts.push("network".to_string()); + } if let Some(file_system) = additional_permissions.file_system.as_ref() { if let Some(read) = file_system.read.as_ref() { let reads = read @@ -1074,6 +1077,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: Some(PermissionProfile { + network: Some(true), file_system: Some(FileSystemPermissions { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), @@ -1100,6 +1104,10 @@ mod tests { .any(|line| line.contains("Permission rule:")), "expected permission-rule line, got {rendered:?}" ); + assert!( + rendered.iter().any(|line| line.contains("network;")), + "expected network permission text, got {rendered:?}" + ); } #[test] @@ -1115,6 +1123,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: Some(PermissionProfile { + network: Some(true), file_system: Some(FileSystemPermissions { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap index 5b04b78b12..989f80f572 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap @@ -7,7 +7,7 @@ expression: "render_overlay_lines(&view, 120)" Reason: need filesystem access - Permission rule: read `/tmp/readme.txt`; write `/tmp/out.txt` + Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` $ cat /tmp/readme.txt diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index a5c9b31fdd..a65d6b2ce9 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -4,7 +4,13 @@ use codex_protocol::protocol::SandboxPolicy; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly { .. } => "read-only".to_string(), + SandboxPolicy::ReadOnly { network_access, .. } => { + let mut summary = "read-only".to_string(); + if *network_access { + summary.push_str(" (network access enabled)"); + } + summary + } SandboxPolicy::ExternalSandbox { network_access } => { let mut summary = "external-sandbox".to_string(); if matches!(network_access, NetworkAccess::Enabled) { @@ -66,6 +72,15 @@ mod tests { assert_eq!(summary, "external-sandbox (network access enabled)"); } + #[test] + fn summarizes_read_only_with_enabled_network() { + let summary = summarize_sandbox_policy(&SandboxPolicy::ReadOnly { + access: Default::default(), + network_access: true, + }); + assert_eq!(summary, "read-only (network access enabled)"); + } + #[test] fn workspace_write_summary_still_includes_network_access() { let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; From bfff0c729f51b84eba5bb423b1ca4eb8317e91fd Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 3 Mar 2026 20:40:22 -0800 Subject: [PATCH 13/23] config: enforce enterprise feature requirements (#13388) ## Why Enterprises can already constrain approvals, sandboxing, and web search through `requirements.toml` and MDM, but feature flags were still only configurable as managed defaults. That meant an enterprise could suggest feature values, but it could not actually pin them. This change closes that gap and makes enterprise feature requirements behave like the other constrained settings. The effective feature set now stays consistent with enterprise requirements during config load, when config writes are validated, and when runtime code mutates feature flags later in the session. It also tightens the runtime API for managed features. `ManagedFeatures` now follows the same constraint-oriented shape as `Constrained` instead of exposing panic-prone mutation helpers, and production code can no longer construct it through an unconstrained `From` path. The PR also hardens the `compact_resume_fork` integration coverage on Windows. After the feature-management changes, `compact_resume_after_second_compaction_preserves_history` was overflowing the libtest/Tokio thread stacks on Windows, so the test now uses an explicit larger-stack harness as a pragmatic mitigation. That may not be the ideal root-cause fix, and it merits a parallel investigation into whether part of the async future chain should be boxed to reduce stack pressure instead. ## What Changed Enterprises can now pin feature values in `requirements.toml` with the requirements-side `features` table: ```toml [features] personality = true unified_exec = false ``` Only canonical feature keys are allowed in the requirements `features` table; omitted keys remain unconstrained. - Added a requirements-side pinned feature map to `ConfigRequirementsToml`, threaded it through source-preserving requirements merge and normalization in `codex-config`, and made the TOML surface use `[features]` (while still accepting legacy `[feature_requirements]` for compatibility). - Exposed `featureRequirements` from `configRequirements/read`, regenerated the JSON/TypeScript schema artifacts, and updated the app-server README. - Wrapped the effective feature set in `ManagedFeatures`, backed by `ConstrainedWithSource`, and changed its API to mirror `Constrained`: `can_set(...)`, `set(...) -> ConstraintResult<()>`, and result-returning `enable` / `disable` / `set_enabled` helpers. - Removed the legacy-usage and bulk-map passthroughs from `ManagedFeatures`; callers that need those behaviors now mutate a plain `Features` value and reapply it through `set(...)`, so the constrained wrapper remains the enforcement boundary. - Removed the production loophole for constructing unconstrained `ManagedFeatures`. Non-test code now creates it through the configured feature-loading path, and `impl From for ManagedFeatures` is restricted to `#[cfg(test)]`. - Rejected legacy feature aliases in enterprise feature requirements, and return a load error when a pinned combination cannot survive dependency normalization. - Validated config writes against enterprise feature requirements before persisting changes, including explicit conflicting writes and profile-specific feature states that normalize into invalid combinations. - Updated runtime and TUI feature-toggle paths to use the constrained setter API and to persist or apply the effective post-constraint value rather than the requested value. - Updated the `core_test_support` Bazel target to include the bundled core model-catalog fixtures in its runtime data, so helper code that resolves `core/models.json` through runfiles works in remote Bazel test environments. - Renamed the core config test coverage to emphasize that effective feature values are normalized at runtime, while conflicting persisted config writes are rejected. - Ran `compact_resume_after_second_compaction_preserves_history` inside an explicit 8 MiB test thread and Tokio runtime worker stack, following the existing larger-stack integration-test pattern, to keep the Windows `compact_resume_fork` test slice from aborting while a parallel investigation continues into whether some of the underlying async futures should be boxed. ## Verification - `cargo test -p codex-config` - `cargo test -p codex-core feature_requirements_ -- --nocapture` - `cargo test -p codex-core load_requirements_toml_produces_expected_constraints -- --nocapture` - `cargo test -p codex-core compact_resume_after_second_compaction_preserves_history -- --nocapture` - `cargo test -p codex-core compact_resume_fork -- --nocapture` - Re-ran the built `codex-core` `tests/all` binary with `RUST_MIN_STACK=262144` for `compact_resume_after_second_compaction_preserves_history` to confirm the explicit-stack harness fixes the deterministic low-stack repro. - `cargo test -p codex-core` - This still fails locally in unrelated integration areas that expect the `codex` / `test_stdio_server` binaries or hit existing `search_tool` wiremock mismatches. ## Docs `developers.openai.com/codex` should document the requirements-side `[features]` table for enterprise and MDM-managed configuration, including that it only accepts canonical feature keys and that conflicting config writes are rejected. --- .../codex_app_server_protocol.schemas.json | 9 + .../codex_app_server_protocol.v2.schemas.json | 9 + .../v2/ConfigRequirementsReadResponse.json | 9 + .../typescript/v2/ConfigRequirements.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 8 +- codex-rs/app-server/src/config_api.rs | 17 + codex-rs/cloud-requirements/src/lib.rs | 12 + codex-rs/config/src/config_requirements.rs | 76 ++++ codex-rs/config/src/lib.rs | 1 + codex-rs/core/src/agent/control.rs | 5 +- codex-rs/core/src/codex.rs | 38 +- codex-rs/core/src/config/managed_features.rs | 344 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 189 ++++++++-- codex-rs/core/src/config/service.rs | 135 +++++++ codex-rs/core/src/config_loader/mod.rs | 1 + codex-rs/core/src/config_loader/tests.rs | 25 ++ codex-rs/core/src/features.rs | 29 +- codex-rs/core/src/mcp/mod.rs | 15 +- codex-rs/core/src/memories/phase2.rs | 2 +- codex-rs/core/src/project_doc.rs | 24 +- codex-rs/core/src/rollout/recorder.rs | 15 +- codex-rs/core/src/tasks/review.rs | 2 +- .../core/src/tools/handlers/multi_agents.rs | 2 +- codex-rs/core/src/tools/js_repl/mod.rs | 3 +- codex-rs/core/tests/common/BUILD.bazel | 3 + codex-rs/core/tests/common/test_codex.rs | 84 ++++- codex-rs/core/tests/common/zsh_fork.rs | 10 +- codex-rs/core/tests/suite/agent_jobs.rs | 40 +- codex-rs/core/tests/suite/agent_websocket.rs | 20 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 5 +- codex-rs/core/tests/suite/approvals.rs | 5 +- codex-rs/core/tests/suite/client.rs | 10 +- .../core/tests/suite/client_websockets.rs | 20 +- codex-rs/core/tests/suite/compact.rs | 2 +- .../core/tests/suite/compact_resume_fork.rs | 31 +- .../core/tests/suite/deprecation_notice.rs | 15 +- codex-rs/core/tests/suite/exec_policy.rs | 30 +- .../core/tests/suite/hierarchical_agents.rs | 10 +- codex-rs/core/tests/suite/js_repl.rs | 15 +- codex-rs/core/tests/suite/memories.rs | 31 +- codex-rs/core/tests/suite/model_switching.rs | 5 +- .../core/tests/suite/model_visible_layout.rs | 10 +- codex-rs/core/tests/suite/otel.rs | 60 ++- codex-rs/core/tests/suite/personality.rs | 60 ++- codex-rs/core/tests/suite/plugins.rs | 11 +- codex-rs/core/tests/suite/prompt_caching.rs | 40 +- .../core/tests/suite/request_compression.rs | 10 +- .../core/tests/suite/request_permissions.rs | 30 +- .../core/tests/suite/request_user_input.rs | 6 +- codex-rs/core/tests/suite/search_tool.rs | 17 +- codex-rs/core/tests/suite/shell_command.rs | 12 +- codex-rs/core/tests/suite/shell_snapshot.rs | 40 +- codex-rs/core/tests/suite/sqlite_state.rs | 30 +- .../tests/suite/subagent_notifications.rs | 11 +- codex-rs/core/tests/suite/tool_harness.rs | 10 +- codex-rs/core/tests/suite/tools.rs | 10 +- codex-rs/core/tests/suite/undo.rs | 5 +- codex-rs/core/tests/suite/unified_exec.rs | 130 +++++-- .../tests/suite/unstable_features_warning.rs | 10 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 5 +- codex-rs/core/tests/suite/view_image.rs | 21 +- codex-rs/core/tests/suite/web_search.rs | 25 +- .../core/tests/suite/websocket_fallback.rs | 20 +- codex-rs/tui/src/app.rs | 40 +- codex-rs/tui/src/chatwidget.rs | 14 +- codex-rs/tui/src/chatwidget/tests.rs | 40 +- codex-rs/tui/src/debug_config.rs | 2 + codex-rs/tui/src/lib.rs | 5 +- 70 files changed, 1718 insertions(+), 268 deletions(-) create mode 100644 codex-rs/core/src/config/managed_features.rs diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d7ba5671ee..374bdc1971 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9007,6 +9007,15 @@ "type": "null" } ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5cb5a7594c..2ca096cff1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2881,6 +2881,15 @@ "type": "null" } ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 651e035e9c..c4a06943aa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -81,6 +81,15 @@ "type": "null" } ] + }, + "featureRequirements": { + "additionalProperties": { + "type": "boolean" + }, + "type": [ + "object", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index f99c880697..47a99453fe 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8c5dfedb16..7c69d9fa11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; @@ -611,6 +612,7 @@ pub struct ConfigRequirements { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, + pub feature_requirements: Option>, pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 786a935b6d..b105d411b1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -165,7 +165,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `enforceResidency`, and `network` constraints. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f154bff311..80558642cb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4550,11 +4550,9 @@ impl CodexMessageProcessor { } }; - if thread.enabled(Feature::Apps) { - config.features.enable(Feature::Apps); - } else { - config.features.disable(Feature::Apps); - } + let _ = config + .features + .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); } if !config.features.enabled(Feature::Apps) { diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index c9317ac9f5..4b0b66ccab 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -127,6 +127,9 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } normalized }), + feature_requirements: requirements + .feature_requirements + .map(|requirements| requirements.entries), enforce_residency: requirements .enforce_residency .map(map_residency_requirement_to_api), @@ -212,6 +215,12 @@ mod tests { allowed_web_search_modes: Some(vec![ codex_core::config_loader::WebSearchModeRequirement::Cached, ]), + feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml { + entries: std::collections::BTreeMap::from([ + ("apps".to_string(), false), + ("personality".to_string(), true), + ]), + }), mcp_servers: None, rules: None, enforce_residency: Some(CoreResidencyRequirement::Us), @@ -247,6 +256,13 @@ mod tests { mapped.allowed_web_search_modes, Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]), ); + assert_eq!( + mapped.feature_requirements, + Some(std::collections::BTreeMap::from([ + ("apps".to_string(), false), + ("personality".to_string(), true), + ])), + ); assert_eq!( mapped.enforce_residency, Some(codex_app_server_protocol::ResidencyRequirement::Us), @@ -275,6 +291,7 @@ mod tests { allowed_approval_policies: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 2b51a9e9eb..9e4a8b002d 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -761,6 +761,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -803,6 +804,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -856,6 +858,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -912,6 +915,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -939,6 +943,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -986,6 +991,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1032,6 +1038,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1082,6 +1089,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1133,6 +1141,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1184,6 +1193,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1272,6 +1282,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -1295,6 +1306,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d895ef0736..40af63f7af 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -79,6 +79,7 @@ pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, pub sandbox_policy: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, + pub feature_requirements: Option>, pub mcp_servers: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, @@ -101,6 +102,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(WebSearchMode::Cached), None, ), + feature_requirements: None, mcp_servers: None, exec_policy: None, enforce_residency: ConstrainedWithSource::new(Constrained::allow_any(None), None), @@ -227,12 +229,26 @@ impl fmt::Display for WebSearchModeRequirement { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct FeatureRequirementsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FeatureRequirementsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, + #[serde(rename = "features", alias = "feature_requirements")] + pub feature_requirements: Option, pub mcp_servers: Option>, pub rules: Option, pub enforce_residency: Option, @@ -267,6 +283,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, + pub feature_requirements: Option>, pub mcp_servers: Option>>, pub rules: Option>, pub enforce_residency: Option>, @@ -302,6 +319,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, allowed_web_search_modes, + feature_requirements, mcp_servers, rules, enforce_residency, @@ -315,6 +333,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_sandbox_modes, allowed_web_search_modes, + feature_requirements, mcp_servers, rules, enforce_residency, @@ -324,6 +343,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + feature_requirements: feature_requirements.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), @@ -370,6 +390,10 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_web_search_modes.is_none() + && self + .feature_requirements + .as_ref() + .is_none_or(FeatureRequirementsToml::is_empty) && self.mcp_servers.is_none() && self.rules.is_none() && self.enforce_residency.is_none() @@ -385,6 +409,7 @@ impl TryFrom for ConfigRequirements { allowed_approval_policies, allowed_sandbox_modes, allowed_web_search_modes, + feature_requirements, mcp_servers, rules, enforce_residency, @@ -521,6 +546,8 @@ impl TryFrom for ConfigRequirements { } None => ConstrainedWithSource::new(Constrained::allow_any(WebSearchMode::Cached), None), }; + let feature_requirements = + feature_requirements.filter(|requirements| !requirements.value.is_empty()); let enforce_residency = match enforce_residency { Some(Sourced { @@ -553,6 +580,7 @@ impl TryFrom for ConfigRequirements { approval_policy, sandbox_policy, web_search_mode, + feature_requirements, mcp_servers, exec_policy, enforce_residency, @@ -588,6 +616,7 @@ mod tests { allowed_approval_policies, allowed_sandbox_modes, allowed_web_search_modes, + feature_requirements, mcp_servers, rules, enforce_residency, @@ -600,6 +629,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + feature_requirements: feature_requirements + .map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), enforce_residency: enforce_residency @@ -622,6 +653,9 @@ mod tests { WebSearchModeRequirement::Cached, WebSearchModeRequirement::Live, ]; + let feature_requirements = FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); @@ -631,6 +665,7 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + feature_requirements: Some(feature_requirements.clone()), mcp_servers: None, rules: None, enforce_residency: Some(enforce_residency), @@ -651,6 +686,10 @@ mod tests { allowed_web_search_modes, enforce_source.clone(), )), + feature_requirements: Some(Sourced::new( + feature_requirements, + enforce_source.clone(), + )), mcp_servers: None, rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), @@ -683,6 +722,7 @@ mod tests { )), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -723,6 +763,7 @@ mod tests { )), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -809,6 +850,8 @@ mod tests { allowed_sandbox_modes = ["read-only"] allowed_web_search_modes = ["cached"] enforce_residency = "us" + [features] + personality = true "#, )?; @@ -829,6 +872,13 @@ mod tests { requirements.web_search_mode.source, Some(source_location.clone()) ); + assert_eq!( + requirements + .feature_requirements + .as_ref() + .map(|requirements| requirements.source.clone()), + Some(source_location.clone()) + ); assert_eq!(requirements.enforce_residency.source, Some(source_location)); Ok(()) @@ -1038,6 +1088,32 @@ mod tests { Ok(()) } + #[test] + fn deserialize_feature_requirements() -> Result<()> { + let toml_str = r#" + [features] + apps = false + personality = true + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.feature_requirements, + Some(Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([ + ("apps".to_string(), false), + ("personality".to_string(), true), + ]), + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + #[test] fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index b85e99133d..c8e25e6e60 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -16,6 +16,7 @@ pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::ConfigRequirementsWithSources; pub use config_requirements::ConstrainedWithSource; +pub use config_requirements::FeatureRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::NetworkConstraints; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 85f28b4f6b..fa643ff3ce 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1381,7 +1381,10 @@ mod tests { #[tokio::test] async fn resume_thread_subagent_restores_stored_nickname_and_role() { let (home, mut config) = test_config().await; - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); let manager = ThreadManager::with_models_provider_and_home_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 87aac73185..9b9e0214a8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -23,11 +23,11 @@ use crate::compact::InitialContextInjection; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; +use crate::config::ManagedFeatures; use crate::connectors; use crate::exec_policy::ExecPolicyManager; use crate::features::FEATURES; use crate::features::Feature; -use crate::features::Features; use crate::features::maybe_push_unstable_features_warning; #[cfg(test)] use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -373,18 +373,24 @@ impl Codex { if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source && depth >= config.agent_max_depth { - config.features.disable(Feature::Collab); + let _ = config.features.disable(Feature::Collab); } if config.features.enabled(Feature::JsRepl) && let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await { - let message = format!( - "Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}" - ); + let _ = config.features.disable(Feature::JsRepl); + let _ = config.features.disable(Feature::JsReplToolsOnly); + let message = if config.features.enabled(Feature::JsRepl) { + format!( + "`js_repl` remains enabled because enterprise requirements pin it on, but the configured Node runtime is unavailable or incompatible. {err}" + ) + } else { + format!( + "Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}" + ) + }; warn!("{message}"); - config.features.disable(Feature::JsRepl); - config.features.disable(Feature::JsReplToolsOnly); config.startup_warnings.push(message); } @@ -620,7 +626,7 @@ pub(crate) struct Session { state: Mutex, /// The set of enabled features should be invariant for the lifetime of the /// session. - features: Features, + features: ManagedFeatures, pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, @@ -674,7 +680,7 @@ pub(crate) struct TurnContext { pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, - pub(crate) features: Features, + pub(crate) features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, @@ -3020,7 +3026,7 @@ impl Session { self.features.enabled(feature) } - pub(crate) fn features(&self) -> Features { + pub(crate) fn features(&self) -> ManagedFeatures { self.features.clone() } @@ -4719,9 +4725,8 @@ async fn spawn_review_thread( .await; // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); - review_features - .disable(crate::features::Feature::WebSearchRequest) - .disable(crate::features::Feature::WebSearchCached); + let _ = review_features.disable(crate::features::Feature::WebSearchRequest); + let _ = review_features.disable(crate::features::Feature::WebSearchCached); let review_web_search_mode = WebSearchMode::Disabled; let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &review_model_info, @@ -8260,7 +8265,10 @@ mod tests { async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); let mut config = build_test_config(codex_home.path()).await; - config.features.enable(Feature::ShellZshFork); + config + .features + .enable(Feature::ShellZshFork) + .expect("test config should allow shell_zsh_fork"); config.zsh_path = None; let config = Arc::new(config); @@ -8813,7 +8821,7 @@ mod tests { #[tokio::test] async fn record_model_warning_appends_user_message() { let (mut session, turn_context) = make_session_and_context().await; - let features = Features::with_defaults(); + let features = crate::features::Features::with_defaults().into(); session.features = features; session diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs new file mode 100644 index 0000000000..13510b0d85 --- /dev/null +++ b/codex-rs/core/src/config/managed_features.rs @@ -0,0 +1,344 @@ +use std::collections::BTreeMap; + +use codex_config::Constrained; +use codex_config::ConstrainedWithSource; +use codex_config::ConstraintError; +use codex_config::ConstraintResult; +use codex_config::FeatureRequirementsToml; +use codex_config::RequirementSource; +use codex_config::Sourced; + +use crate::config::ConfigToml; +use crate::config::profile::ConfigProfile; +use crate::features::Feature; +use crate::features::FeatureOverrides; +use crate::features::Features; +use crate::features::canonical_feature_for_key; +use crate::features::feature_for_key; + +/// Wrapper around [`Features`] which enforces constraints defined in +/// `FeatureRequirementsToml` and provides normalization to ensure constraints +/// are satisfied. Constraints are enforced on construction and mutation of +/// `ManagedFeatures`. +#[derive(Debug, Clone, PartialEq)] +pub struct ManagedFeatures { + value: ConstrainedWithSource, + pinned_features: BTreeMap, +} + +impl ManagedFeatures { + pub(crate) fn from_configured( + configured_features: Features, + feature_requirements: Option>, + ) -> std::io::Result { + let (pinned_features, source) = match feature_requirements { + Some(Sourced { + value: feature_requirements, + source, + }) => ( + parse_feature_requirements(feature_requirements, &source)?, + Some(source), + ), + None => (BTreeMap::new(), None), + }; + + let normalized_features = normalize_candidate(configured_features, &pinned_features); + validate_pinned_features(&normalized_features, &pinned_features, source.as_ref())?; + Ok(Self { + value: ConstrainedWithSource::new(Constrained::allow_any(normalized_features), source), + pinned_features, + }) + } + + pub fn get(&self) -> &Features { + self.value.get() + } + + fn normalize_and_validate(&self, candidate: Features) -> ConstraintResult { + let normalized = normalize_candidate(candidate, &self.pinned_features); + self.value.can_set(&normalized)?; + validate_pinned_features_constraint( + &normalized, + &self.pinned_features, + self.value.source.as_ref(), + )?; + Ok(normalized) + } + + pub fn can_set(&self, candidate: &Features) -> ConstraintResult<()> { + self.normalize_and_validate(candidate.clone()).map(|_| ()) + } + + pub fn set(&mut self, candidate: Features) -> ConstraintResult<()> { + let normalized = self.normalize_and_validate(candidate)?; + self.value.value.set(normalized) + } + + pub fn set_enabled(&mut self, feature: Feature, enabled: bool) -> ConstraintResult<()> { + let mut next = self.get().clone(); + next.set_enabled(feature, enabled); + self.set(next) + } + + pub fn enable(&mut self, feature: Feature) -> ConstraintResult<()> { + self.set_enabled(feature, true) + } + + pub fn disable(&mut self, feature: Feature) -> ConstraintResult<()> { + self.set_enabled(feature, false) + } +} + +/// Only available for tests to ensure `ManagedFeatures` is constructed with +/// any required constraints taken into account. +#[cfg(test)] +impl From for ManagedFeatures { + fn from(features: Features) -> Self { + Self { + value: ConstrainedWithSource::new(Constrained::allow_any(features), None), + pinned_features: BTreeMap::new(), + } + } +} + +impl std::ops::Deref for ManagedFeatures { + type Target = Features; + + fn deref(&self) -> &Self::Target { + self.get() + } +} + +fn normalize_candidate( + mut candidate: Features, + pinned_features: &BTreeMap, +) -> Features { + for (feature, enabled) in pinned_features { + candidate.set_enabled(*feature, *enabled); + } + candidate.normalize_dependencies(); + candidate +} + +fn validate_pinned_features_constraint( + normalized_features: &Features, + pinned_features: &BTreeMap, + source: Option<&RequirementSource>, +) -> ConstraintResult<()> { + let Some(source) = source else { + return Ok(()); + }; + let allowed = feature_requirements_display(pinned_features); + for (feature, enabled) in pinned_features { + if normalized_features.enabled(*feature) != *enabled { + return Err(ConstraintError::InvalidValue { + field_name: "features", + candidate: format!( + "{}={}", + feature.key(), + normalized_features.enabled(*feature) + ), + allowed, + requirement_source: source.clone(), + }); + } + } + + Ok(()) +} + +fn validate_pinned_features( + normalized_features: &Features, + pinned_features: &BTreeMap, + source: Option<&RequirementSource>, +) -> std::io::Result<()> { + validate_pinned_features_constraint(normalized_features, pinned_features, source) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) +} + +fn feature_requirements_display(feature_requirements: &BTreeMap) -> String { + let values = feature_requirements + .iter() + .map(|(feature, enabled)| format!("{}={enabled}", feature.key())) + .collect::>(); + format!("[{}]", values.join(", ")) +} + +fn parse_feature_requirements( + feature_requirements: FeatureRequirementsToml, + source: &RequirementSource, +) -> std::io::Result> { + let mut pinned_features = BTreeMap::new(); + for (key, enabled) in feature_requirements.entries { + if let Some(feature) = canonical_feature_for_key(&key) { + pinned_features.insert(feature, enabled); + continue; + } + + if let Some(feature) = feature_for_key(&key) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "invalid `features` requirement `{key}` from {source}: use canonical feature key `{}`", + feature.key() + ), + )); + } + + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid `features` requirement `{key}` from {source}"), + )); + } + + Ok(pinned_features) +} + +fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature, bool)> { + let mut explicit_settings = Vec::new(); + + if let Some(features) = cfg.features.as_ref() { + for (key, enabled) in &features.entries { + if let Some(feature) = feature_for_key(key) { + explicit_settings.push((format!("features.{key}"), feature, *enabled)); + } + } + } + if let Some(enabled) = cfg.experimental_use_unified_exec_tool { + explicit_settings.push(( + "experimental_use_unified_exec_tool".to_string(), + Feature::UnifiedExec, + enabled, + )); + } + if let Some(enabled) = cfg.experimental_use_freeform_apply_patch { + explicit_settings.push(( + "experimental_use_freeform_apply_patch".to_string(), + Feature::ApplyPatchFreeform, + enabled, + )); + } + if let Some(enabled) = cfg.tools.as_ref().and_then(|tools| tools.web_search) { + explicit_settings.push(( + "tools.web_search".to_string(), + Feature::WebSearchRequest, + enabled, + )); + } + + for (profile_name, profile) in &cfg.profiles { + if let Some(features) = profile.features.as_ref() { + for (key, enabled) in &features.entries { + if let Some(feature) = feature_for_key(key) { + explicit_settings.push(( + format!("profiles.{profile_name}.features.{key}"), + feature, + *enabled, + )); + } + } + } + if let Some(enabled) = profile.include_apply_patch_tool { + explicit_settings.push(( + format!("profiles.{profile_name}.include_apply_patch_tool"), + Feature::ApplyPatchFreeform, + enabled, + )); + } + if let Some(enabled) = profile.experimental_use_unified_exec_tool { + explicit_settings.push(( + format!("profiles.{profile_name}.experimental_use_unified_exec_tool"), + Feature::UnifiedExec, + enabled, + )); + } + if let Some(enabled) = profile.experimental_use_freeform_apply_patch { + explicit_settings.push(( + format!("profiles.{profile_name}.experimental_use_freeform_apply_patch"), + Feature::ApplyPatchFreeform, + enabled, + )); + } + if let Some(enabled) = profile.tools_web_search { + explicit_settings.push(( + format!("profiles.{profile_name}.tools_web_search"), + Feature::WebSearchRequest, + enabled, + )); + } + } + + explicit_settings +} + +pub(crate) fn validate_explicit_feature_settings_in_config_toml( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result<()> { + let Some(Sourced { + value: feature_requirements, + source, + }) = feature_requirements + else { + return Ok(()); + }; + + let pinned_features = parse_feature_requirements(feature_requirements.clone(), source)?; + if pinned_features.is_empty() { + return Ok(()); + } + + let allowed = feature_requirements_display(&pinned_features); + for (path, feature, enabled) in explicit_feature_settings_in_config(cfg) { + if pinned_features + .get(&feature) + .is_some_and(|required| *required != enabled) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + ConstraintError::InvalidValue { + field_name: "features", + candidate: format!("{path}={enabled}"), + allowed, + requirement_source: source.clone(), + }, + )); + } + } + + Ok(()) +} + +pub(crate) fn validate_feature_requirements_in_config_toml( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result<()> { + fn validate_profile( + cfg: &ConfigToml, + profile_name: Option<&str>, + profile: &ConfigProfile, + feature_requirements: Option<&Sourced>, + ) -> std::io::Result<()> { + let configured_features = Features::from_config(cfg, profile, FeatureOverrides::default()); + ManagedFeatures::from_configured(configured_features, feature_requirements.cloned()) + .map(|_| ()) + .map_err(|err| { + if let Some(profile_name) = profile_name { + std::io::Error::new( + err.kind(), + format!( + "invalid feature configuration for profile `{profile_name}`: {err}" + ), + ) + } else { + err + } + }) + } + + validate_profile(cfg, None, &ConfigProfile::default(), feature_requirements)?; + for (profile_name, profile) in &cfg.profiles { + validate_profile(cfg, Some(profile_name), profile, feature_requirements)?; + } + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index bb313f25f3..3b71f8fac4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -91,6 +91,7 @@ use toml::Value as TomlValue; use toml_edit::DocumentMut; pub mod edit; +mod managed_features; mod network_proxy_spec; mod permissions; pub mod profile; @@ -102,6 +103,7 @@ pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; pub use codex_network_proxy::NetworkProxyAuditMetadata; +pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub use permissions::NetworkToml; @@ -475,7 +477,7 @@ pub struct Config { pub ghost_snapshot: GhostSnapshotConfig, /// Centralized feature flags; source of truth for feature gating. - pub features: Features, + pub features: ManagedFeatures, /// When `true`, suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: bool, @@ -1684,7 +1686,19 @@ impl Config { codex_home: PathBuf, config_layer_stack: ConfigLayerStack, ) -> std::io::Result { - let requirements = config_layer_stack.requirements().clone(); + // Ensure that every field of ConfigRequirements is applied to the final + // Config. + let ConfigRequirements { + approval_policy: mut constrained_approval_policy, + sandbox_policy: mut constrained_sandbox_policy, + web_search_mode: mut constrained_web_search_mode, + feature_requirements, + mcp_servers, + exec_policy: _, + enforce_residency, + network: network_requirements, + } = config_layer_stack.requirements().clone(); + let user_instructions = Self::load_instructions(Some(&codex_home)); let mut startup_warnings = Vec::new(); @@ -1739,7 +1753,8 @@ impl Config { web_search_request: override_tools_web_search_request, }; - let features = Features::from_config(&cfg, &config_profile, feature_overrides); + let configured_features = Features::from_config(&cfg, &config_profile, feature_overrides); + let features = ManagedFeatures::from_configured(configured_features, feature_requirements)?; let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let resolved_cwd = { use std::env; @@ -1780,7 +1795,7 @@ impl Config { config_profile.sandbox_mode, windows_sandbox_level, &resolved_cwd, - Some(&requirements.sandbox_policy), + Some(&constrained_sandbox_policy), ); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { for path in additional_writable_roots { @@ -1805,13 +1820,13 @@ impl Config { } }); if !approval_policy_was_explicit - && let Err(err) = requirements.approval_policy.can_set(&approval_policy) + && let Err(err) = constrained_approval_policy.can_set(&approval_policy) { tracing::warn!( error = %err, "default approval policy is disallowed by requirements; falling back to required default" ); - approval_policy = requirements.approval_policy.value(); + approval_policy = constrained_approval_policy.value(); } let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) .unwrap_or(WebSearchMode::Cached); @@ -2052,18 +2067,6 @@ impl Config { .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) .unwrap_or_else(|| codex_home.to_path_buf()); - // Ensure that every field of ConfigRequirements is applied to the final - // Config. - let ConfigRequirements { - approval_policy: mut constrained_approval_policy, - sandbox_policy: mut constrained_sandbox_policy, - web_search_mode: mut constrained_web_search_mode, - mcp_servers, - exec_policy: _, - enforce_residency, - network: network_requirements, - } = requirements; - apply_requirement_constrained_value( "approval_policy", approval_policy, @@ -4967,7 +4970,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), - features: Features::with_defaults(), + features: Features::with_defaults().into(), suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -5097,7 +5100,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), - features: Features::with_defaults(), + features: Features::with_defaults().into(), suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -5225,7 +5228,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), - features: Features::with_defaults(), + features: Features::with_defaults().into(), suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -5339,7 +5342,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), - features: Features::with_defaults(), + features: Features::with_defaults().into(), suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -5394,6 +5397,7 @@ model_verbosity = "high" allowed_web_search_modes: Some(vec![ crate::config_loader::WebSearchModeRequirement::Cached, ]), + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -5998,6 +6002,7 @@ mcp_oauth_callback_url = "https://example.com/callback" crate::config_loader::SandboxModeRequirement::ReadOnly, ]), allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -6116,6 +6121,146 @@ trust_level = "untrusted" ); Ok(()) } + + #[tokio::test] + async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `features`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) + } + + #[tokio::test] + async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +personality = false +shell_tool = true +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("Configured value for `features`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) + } + + #[tokio::test] + async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("personality".to_string(), true), + ("shell_tool".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + let mut requested = config.features.get().clone(); + requested + .disable(Feature::Personality) + .enable(Feature::ShellTool); + assert!(config.features.can_set(&requested).is_ok()); + config + .features + .set(requested) + .expect("managed feature mutations should normalize successfully"); + + assert!(config.features.enabled(Feature::Personality)); + assert!(!config.features.enabled(Feature::ShellTool)); + + Ok(()) + } + + #[tokio::test] + async fn feature_requirements_reject_legacy_aliases() { + let codex_home = TempDir::new().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("collab".to_string(), true)]), + }), + ..Default::default() + })) + })) + .build() + .await + .expect_err("legacy aliases should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("use canonical feature key `multi_agent`"), + "{err}" + ); + } + #[test] fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index da675d2389..10e0679e57 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -1,6 +1,9 @@ use super::ConfigToml; +use super::deserialize_config_toml_with_base; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; +use crate::config::managed_features::validate_explicit_feature_settings_in_config_toml; +use crate::config::managed_features::validate_feature_requirements_in_config_toml; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; @@ -331,6 +334,35 @@ impl ConfigService { format!("Invalid configuration: {err}"), ) })?; + let user_config_toml = + deserialize_config_toml_with_base(user_config.clone(), &self.codex_home).map_err( + |err| { + ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + }, + )?; + validate_explicit_feature_settings_in_config_toml( + &user_config_toml, + layers.requirements().feature_requirements.as_ref(), + ) + .map_err(|err| { + ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + validate_feature_requirements_in_config_toml( + &user_config_toml, + layers.requirements().feature_requirements.as_ref(), + ) + .map_err(|err| { + ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; let updated_layers = layers.with_user_config(&provided_path, user_config.clone()); let effective = updated_layers.effective_config(); @@ -706,6 +738,7 @@ mod tests { use codex_app_server_protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; use tempfile::tempdir; #[test] @@ -1088,6 +1121,108 @@ personality = true assert_eq!(contents.trim(), "model = \"user\""); } + #[tokio::test] + async fn write_value_rejects_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: None, + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("invalid value for `features`: `features.personality=false`"), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); + } + + #[tokio::test] + async fn write_value_rejects_profile_feature_requirement_conflict() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: None, + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }), + ..Default::default() + })) + }), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "profiles.enterprise.features.personality".to_string(), + value: serde_json::json!(false), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("conflicting profile feature write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error.to_string().contains( + "invalid value for `features`: `profiles.enterprise.features.personality=false`" + ), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), + "" + ); + } + #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 63b3be48e9..b94bf81d08 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -34,6 +34,7 @@ pub use codex_config::ConfigLoadError; pub use codex_config::ConfigRequirements; pub use codex_config::ConfigRequirementsToml; pub use codex_config::ConstrainedWithSource; +pub use codex_config::FeatureRequirementsToml; pub use codex_config::LoaderOverrides; pub use codex_config::McpServerIdentity; pub use codex_config::McpServerRequirement; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 983acbe8d1..fb8de08615 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; use tempfile::tempdir; @@ -494,6 +495,9 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul allowed_approval_policies = ["never", "on-request"] allowed_web_search_modes = ["cached"] enforce_residency = "us" + +[features] +personality = true "#, ) .await?; @@ -515,6 +519,15 @@ enforce_residency = "us" .cloned(), Some(vec![crate::config_loader::WebSearchModeRequirement::Cached]) ); + assert_eq!( + config_requirements_toml + .feature_requirements + .as_ref() + .map(|requirements| requirements.value.clone()), + Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }) + ); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( config_requirements.approval_policy.value(), @@ -552,6 +565,15 @@ enforce_residency = "us" config_requirements.enforce_residency.value(), Some(crate::config_loader::ResidencyRequirement::Us) ); + assert_eq!( + config_requirements + .feature_requirements + .as_ref() + .map(|requirements| requirements.value.clone()), + Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }) + ); Ok(()) } @@ -581,6 +603,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -629,6 +652,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, @@ -666,6 +690,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_sandbox_modes: None, allowed_web_search_modes: None, + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 30c38e835f..ef6712fd00 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -245,6 +245,14 @@ impl Features { self } + pub fn set_enabled(&mut self, f: Feature, enabled: bool) -> &mut Self { + if enabled { + self.enable(f) + } else { + self.disable(f) + } + } + pub fn record_legacy_usage_force(&mut self, alias: &str, feature: Feature) { let (summary, details) = legacy_usage_notice(alias, feature); self.legacy_usages.insert(LegacyFeatureUsage { @@ -353,10 +361,7 @@ impl Features { } overrides.apply(&mut features); - if features.enabled(Feature::JsReplToolsOnly) && !features.enabled(Feature::JsRepl) { - tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only"); - features.disable(Feature::JsReplToolsOnly); - } + features.normalize_dependencies(); features } @@ -364,6 +369,13 @@ impl Features { pub fn enabled_features(&self) -> Vec { self.enabled.iter().copied().collect() } + + pub(crate) fn normalize_dependencies(&mut self) { + if self.enabled(Feature::JsReplToolsOnly) && !self.enabled(Feature::JsRepl) { + tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only"); + self.disable(Feature::JsReplToolsOnly); + } + } } fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option) { @@ -404,7 +416,7 @@ fn web_search_details() -> &'static str { } /// Keys accepted in `[features]` tables. -fn feature_for_key(key: &str) -> Option { +pub(crate) fn feature_for_key(key: &str) -> Option { for spec in FEATURES { if spec.key == key { return Some(spec.id); @@ -413,6 +425,13 @@ fn feature_for_key(key: &str) -> Option { legacy::feature_for_key(key) } +pub(crate) fn canonical_feature_for_key(key: &str) -> Option { + FEATURES + .iter() + .find(|spec| spec.key == key) + .map(|spec| spec.id) +} + /// Returns `true` if the provided string matches a known feature toggle key. pub fn is_known_feature_key(key: &str) -> bool { feature_for_key(key).is_some() diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 91a7f74db9..ac7dd2acaf 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -566,7 +566,10 @@ mod tests { fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() { let mut config = crate::config::test_config(); config.chatgpt_base_url = "https://chatgpt.com".to_string(); - config.features.enable(Feature::AppsMcpGateway); + config + .features + .enable(Feature::AppsMcpGateway) + .expect("test config should allow apps gateway"); assert_eq!( codex_apps_mcp_url(&config), @@ -582,7 +585,10 @@ mod tests { let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config); assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); - config.features.enable(Feature::Apps); + config + .features + .enable(Feature::Apps) + .expect("test config should allow apps"); servers = with_codex_apps_mcp(servers, true, None, &config); let server = servers @@ -595,7 +601,10 @@ mod tests { assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); - config.features.enable(Feature::AppsMcpGateway); + config + .features + .enable(Feature::AppsMcpGateway) + .expect("test config should allow apps gateway"); servers = with_codex_apps_mcp(servers, true, None, &config); let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index ff13e4f64c..c8575035e5 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -266,7 +266,7 @@ mod agent { // Approval policy agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); // Consolidation runs as an internal sub-agent and must not recursively delegate. - agent_config.features.disable(Feature::Collab); + let _ = agent_config.features.disable(Feature::Collab); // Sandbox policy let mut writable_roots = Vec::new(); diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index debe39c2ad..2cf884c593 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -475,7 +475,9 @@ mod tests { async fn js_repl_instructions_are_appended_when_enabled() { let tmp = tempfile::tempdir().expect("tempdir"); let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features.enable(Feature::JsRepl); + cfg.features + .enable(Feature::JsRepl) + .expect("test config should allow js_repl"); let res = get_user_instructions(&cfg, None) .await @@ -488,9 +490,13 @@ mod tests { async fn js_repl_tools_only_instructions_are_feature_gated() { let tmp = tempfile::tempdir().expect("tempdir"); let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features + let mut features = cfg.features.get().clone(); + features .enable(Feature::JsRepl) .enable(Feature::JsReplToolsOnly); + cfg.features + .set(features) + .expect("test config should allow js_repl tool restrictions"); let res = get_user_instructions(&cfg, None) .await @@ -503,9 +509,13 @@ mod tests { async fn js_repl_original_resolution_guidance_is_feature_gated() { let tmp = tempfile::tempdir().expect("tempdir"); let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features + let mut features = cfg.features.get().clone(); + features .enable(Feature::JsRepl) .enable(Feature::ImageDetailOriginal); + cfg.features + .set(features) + .expect("test config should allow js_repl image detail settings"); let res = get_user_instructions(&cfg, None) .await @@ -730,7 +740,9 @@ mod tests { async fn apps_feature_does_not_emit_user_instructions_by_itself() { let tmp = tempfile::tempdir().expect("tempdir"); let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features.enable(Feature::Apps); + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); let res = get_user_instructions(&cfg, None).await; assert_eq!(res, None); @@ -742,7 +754,9 @@ mod tests { fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap(); let mut cfg = make_config(&tmp, 4096, None).await; - cfg.features.enable(Feature::Apps); + cfg.features + .enable(Feature::Apps) + .expect("test config should allow apps"); let res = get_user_instructions(&cfg, None) .await diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 7602b75c6a..23edc57ae0 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1207,7 +1207,10 @@ mod tests { .codex_home(home.path().to_path_buf()) .build() .await?; - config.features.disable(Feature::Sqlite); + config + .features + .disable(Feature::Sqlite) + .expect("test config should allow sqlite to be disabled"); let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?; let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?; @@ -1253,7 +1256,10 @@ mod tests { .codex_home(home.path().to_path_buf()) .build() .await?; - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); let uuid = Uuid::from_u128(9010); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); @@ -1319,7 +1325,10 @@ mod tests { .codex_home(home.path().to_path_buf()) .build() .await?; - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); let uuid = Uuid::from_u128(9011); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index dcb3b4cdc8..4192236b48 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -95,7 +95,7 @@ async fn start_review_conversation( { panic!("by construction Constrained must always support Disabled: {err}"); } - sub_agent_config.features.disable(Feature::Collab); + let _ = sub_agent_config.features.disable(Feature::Collab); // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index c179fcef7a..20b001315b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -973,7 +973,7 @@ fn apply_spawn_agent_runtime_overrides( fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { if child_depth >= config.agent_max_depth { - config.features.disable(Feature::Collab); + let _ = config.features.disable(Feature::Collab); } } diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index a8a9123073..86463e1b95 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1989,7 +1989,8 @@ mod tests { let (_session, mut turn) = make_session_and_context().await; Arc::make_mut(&mut turn.config) .features - .enable(Feature::ImageDetailOriginal); + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); turn.model_info.supports_image_detail_original = true; let content_item = diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index abfb79624d..aec0c17817 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -4,4 +4,7 @@ codex_rust_crate( name = "common", crate_name = "core_test_support", crate_srcs = glob(["*.rs"]), + lib_data_extra = [ + "//codex-rs/core:model_availability_nux_fixtures", + ], ) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 392d4d354f..a79838df00 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use anyhow::Context; use anyhow::Result; use codex_core::CodexAuth; use codex_core::CodexThread; @@ -11,12 +12,15 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; +use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use serde_json::Value; use tempfile::TempDir; @@ -28,11 +32,13 @@ use crate::responses::output_value_to_text; use crate::responses::start_mock_server; use crate::streaming_sse::StreamingSseServer; use crate::wait_for_event; +use crate::wait_for_event_match; use wiremock::Match; use wiremock::matchers::path_regex; type ConfigMutator = dyn FnOnce(&mut Config) + Send; type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; +const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex"; /// A collection of different ways the model can output an apply_patch call #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -128,7 +134,10 @@ impl TestCodexBuilder { self.config_mutators.push(Box::new(move |config| { config.model_provider.base_url = Some(base_url_clone); config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); })); self.build_with_home_and_base_url(base_url, home, None) .await @@ -172,11 +181,21 @@ impl TestCodexBuilder { resume_from: Option, ) -> anyhow::Result { let auth = self.auth.clone(); - let thread_manager = codex_core::test_support::thread_manager_with_models_provider_and_home( - auth.clone(), - config.model_provider.clone(), - config.codex_home.clone(), - ); + let thread_manager = if let Some(model_catalog) = config.model_catalog.clone() { + ThreadManager::new( + config.codex_home.clone(), + codex_core::test_support::auth_manager_from_auth(auth.clone()), + SessionSource::Exec, + Some(model_catalog), + CollaborationModesConfig::default(), + ) + } else { + codex_core::test_support::thread_manager_with_models_provider_and_home( + auth.clone(), + config.model_provider.clone(), + config.codex_home.clone(), + ) + }; let thread_manager = Arc::new(thread_manager); let new_conversation = match resume_from { @@ -232,17 +251,56 @@ impl TestCodexBuilder { for mutator in mutators { mutator(&mut config); } + ensure_test_model_catalog(&mut config)?; if config.include_apply_patch_tool { - config.features.enable(Feature::ApplyPatchFreeform); + config.features.enable(Feature::ApplyPatchFreeform)?; } else { - config.features.disable(Feature::ApplyPatchFreeform); + config.features.disable(Feature::ApplyPatchFreeform)?; } Ok((config, cwd)) } } +fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { + if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS) + || config.model_catalog.is_some() + { + return Ok(()); + } + + let bundled_models_path = codex_utils_cargo_bin::find_resource!("../../models.json") + .context("bundled models.json")?; + let bundled_models_contents = + std::fs::read_to_string(&bundled_models_path).with_context(|| { + format!( + "read bundled models.json from {}", + bundled_models_path.display() + ) + })?; + let bundled_models: ModelsResponse = + serde_json::from_str(&bundled_models_contents).context("parse bundled models.json")?; + let mut model = bundled_models + .models + .iter() + .find(|candidate| candidate.slug == "gpt-5.1-codex") + .cloned() + .unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex")); + model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); + model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); + model.experimental_supported_tools = vec![ + "test_sync_tool".to_string(), + "read_file".to_string(), + "grep_files".to_string(), + "list_dir".to_string(), + ]; + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + Ok(()) +} + pub struct TestCodex { pub home: Arc, pub cwd: Arc, @@ -334,8 +392,14 @@ impl TestCodex { }) .await?; - wait_for_event(&self.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) + let turn_id = wait_for_event_match(&self.codex, |event| match event { + EventMsg::TurnStarted(event) => Some(event.turn_id.clone()), + _ => None, + }) + .await; + wait_for_event(&self.codex, |event| match event { + EventMsg::TurnComplete(event) => event.turn_id == turn_id, + _ => false, }) .await; Ok(()) diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index 22ae69303e..fbc46421d9 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -24,8 +24,14 @@ impl ZshForkRuntime { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, ) { - config.features.enable(Feature::ShellTool); - config.features.enable(Feature::ShellZshFork); + config + .features + .enable(Feature::ShellTool) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ShellZshFork) + .expect("test config should allow feature update"); config.zsh_path = Some(self.zsh_path.clone()); config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); config.permissions.allow_login_shell = false; diff --git a/codex-rs/core/tests/suite/agent_jobs.rs b/codex-rs/core/tests/suite/agent_jobs.rs index 5708f400ad..190302a3e1 100644 --- a/codex-rs/core/tests/suite/agent_jobs.rs +++ b/codex-rs/core/tests/suite/agent_jobs.rs @@ -222,8 +222,14 @@ fn parse_simple_csv_line(line: &str) -> Vec { async fn report_agent_job_result_rejects_wrong_thread() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -282,8 +288,14 @@ async fn report_agent_job_result_rejects_wrong_thread() -> Result<()> { async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -319,8 +331,14 @@ async fn spawn_agents_on_csv_dedupes_item_ids() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -371,8 +389,14 @@ async fn spawn_agents_on_csv_dedupes_item_ids() -> Result<()> { async fn spawn_agents_on_csv_stop_halts_future_items() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 0db3cf61ad..5e81452a49 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -183,7 +183,10 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ResponsesWebsocketsV2); + config + .features + .enable(Feature::ResponsesWebsocketsV2) + .expect("test config should allow feature update"); }); let test = builder.build_with_websocket_server(&server).await?; @@ -262,7 +265,10 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm() .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ResponsesWebsocketsV2); + config + .features + .enable(Feature::ResponsesWebsocketsV2) + .expect("test config should allow feature update"); }); let test = builder.build_with_websocket_server(&server).await?; @@ -311,7 +317,10 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ResponsesWebsocketsV2); + config + .features + .enable(Feature::ResponsesWebsocketsV2) + .expect("test config should allow feature update"); config.service_tier = Some(ServiceTier::Fast); }); let test = builder.build_with_websocket_server(&server).await?; @@ -365,7 +374,10 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ResponsesWebsocketsV2); + config + .features + .enable(Feature::ResponsesWebsocketsV2) + .expect("test config should allow feature update"); }); let test = builder.build_with_websocket_server(&server).await?; diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 112b1c1bed..28fdd0b83e 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -674,7 +674,10 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( let harness = apply_patch_harness_with(|builder| { builder.with_config(|config| { - config.features.enable(Feature::ApplyPatchFreeform); + config + .features + .enable(Feature::ApplyPatchFreeform) + .expect("test config should allow feature update"); }) }) .await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 3f82139fe3..9ca159965f 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1598,7 +1598,10 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy.clone()); for feature in features { - config.features.enable(feature); + config + .features + .enable(feature) + .expect("test config should allow feature update"); } }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d6fde7e691..3ac9a72c3c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -939,8 +939,14 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(move |config| { - config.features.enable(Feature::Apps); - config.features.disable(Feature::AppsMcpGateway); + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::AppsMcpGateway) + .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url; }); let codex = builder diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index b2098c039c..4896a0fa1c 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1481,15 +1481,27 @@ async fn websocket_harness_with_options( let mut config = load_default_config_for_test(&codex_home).await; config.model = Some(MODEL.to_string()); if websocket_enabled { - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); } else { - config.features.disable(Feature::ResponsesWebsockets); + config + .features + .disable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); } if runtime_metrics_enabled { - config.features.enable(Feature::RuntimeMetrics); + config + .features + .enable(Feature::RuntimeMetrics) + .expect("test config should allow feature update"); } if websocket_v2_enabled { - config.features.enable(Feature::ResponsesWebsocketsV2); + config + .features + .enable(Feature::ResponsesWebsocketsV2) + .expect("test config should allow feature update"); } let config = Arc::new(config); let mut model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ac6ffc915d..bb34789499 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3112,7 +3112,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config + let _ = config .features .enable(codex_core::features::Feature::RemoteModels); config.model_auto_compact_token_limit = Some(200); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 5b3f409125..fe536077a3 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,6 +10,7 @@ use super::compact::COMPACT_WARNING_MESSAGE; use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; +use anyhow::Result; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; @@ -291,13 +292,36 @@ async fn compact_resume_and_fork_preserve_model_history_view() { assert_eq!(requests.len(), 5); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test] /// Scenario: after the forked branch is compacted, resuming again should reuse /// the compacted history and only append the new user message. -async fn compact_resume_after_second_compaction_preserves_history() { +fn compact_resume_after_second_compaction_preserves_history() -> Result<()> { + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + let handle = std::thread::Builder::new() + .name("compact_resume_after_second_compaction_preserves_history".to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(TEST_STACK_SIZE_BYTES) + .enable_all() + .build()?; + runtime.block_on(compact_resume_after_second_compaction_preserves_history_impl()) + })?; + + match handle.join() { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!( + "compact_resume_after_second_compaction_preserves_history thread panicked" + )), + } +} + +async fn compact_resume_after_second_compaction_preserves_history_impl() -> Result<()> { if network_disabled() { println!("Skipping test because network is disabled in this sandbox"); - return; + return Ok(()); } // 1. Arrange mocked SSE responses for the initial flow plus the second compact. @@ -402,6 +426,7 @@ async fn compact_resume_after_second_compaction_preserves_history() { assert_eq!(chunk, seeded_user_prefix); } } + Ok(()) } fn normalize_line_endings(value: &mut Value) { diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 2a7563bea1..e3cc890ea2 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -26,10 +26,14 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + let mut features = config.features.get().clone(); + features.enable(Feature::UnifiedExec); + features + .record_legacy_usage_force("use_experimental_unified_exec_tool", Feature::UnifiedExec); config .features - .record_legacy_usage_force("use_experimental_unified_exec_tool", Feature::UnifiedExec); + .set(features) + .expect("test config should allow managed feature metadata updates"); config.use_experimental_unified_exec_tool = true; }); @@ -122,7 +126,12 @@ async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow let mut builder = test_codex().with_config(move |config| { let mut entries = BTreeMap::new(); entries.insert("web_search_request".to_string(), enabled); - config.features.apply_map(&entries); + let mut features = config.features.get().clone(); + features.apply_map(&entries); + config + .features + .set(features) + .expect("test config should allow managed feature map updates"); }); let TestCodex { codex, .. } = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index bf8416e97d..5b34b20c71 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -167,7 +167,10 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5").with_config(|config| { - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let call_id = "shell-empty-script-collab"; @@ -219,8 +222,14 @@ async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5").with_config(|config| { - config.features.enable(Feature::UnifiedExec); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let call_id = "unified-exec-empty-script-collab"; @@ -272,7 +281,10 @@ async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> R async fn shell_command_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5").with_config(|config| { - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let call_id = "shell-whitespace-script-collab"; @@ -324,8 +336,14 @@ async fn shell_command_whitespace_script_with_collaboration_mode_does_not_panic( async fn unified_exec_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5").with_config(|config| { - config.features.enable(Feature::UnifiedExec); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let call_id = "unified-exec-whitespace-script-collab"; diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index 5f1f84340d..9eb9015950 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -19,7 +19,10 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ChildAgentsMd); + config + .features + .enable(Feature::ChildAgentsMd) + .expect("test config should allow feature update"); std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); }); let test = builder.build(&server).await.expect("build test codex"); @@ -58,7 +61,10 @@ async fn hierarchical_agents_emits_when_no_project_doc() { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ChildAgentsMd); + config + .features + .enable(Feature::ChildAgentsMd) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/js_repl.rs b/codex-rs/core/tests/suite/js_repl.rs index 31dbe6e60a..c6175b4444 100644 --- a/codex-rs/core/tests/suite/js_repl.rs +++ b/codex-rs/core/tests/suite/js_repl.rs @@ -76,7 +76,10 @@ async fn run_js_repl_turn( calls: &[(&str, &str)], ) -> Result { let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::JsRepl); + config + .features + .enable(Feature::JsRepl) + .expect("test config should allow feature update"); }); let test = builder.build(server).await?; @@ -112,7 +115,10 @@ async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result let old_node = write_too_old_node_script(temp.path())?; let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::JsRepl); + config + .features + .enable(Feature::JsRepl) + .expect("test config should allow feature update"); config.js_repl_node_path = Some(old_node); }); let test = builder.build(&server).await?; @@ -164,7 +170,10 @@ async fn js_repl_persists_top_level_bindings_and_supports_tla() -> Result<()> { let server = responses::start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::JsRepl); + config + .features + .enable(Feature::JsRepl) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/memories.rs b/codex-rs/core/tests/suite/memories.rs index 80e2d6ff6d..7fb4fe0bb2 100644 --- a/codex-rs/core/tests/suite/memories.rs +++ b/codex-rs/core/tests/suite/memories.rs @@ -166,8 +166,14 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs() let db = init_state_db(&home).await?; let mut initial_builder = test_codex().with_home(home.clone()).with_config(|config| { - config.features.enable(Feature::Sqlite); - config.features.enable(Feature::MemoryTool); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MemoryTool) + .expect("test config should allow feature update"); config.memories.max_raw_memories_for_consolidation = 1; config.memories.no_memories_if_mcp_or_web_search = true; }); @@ -232,8 +238,14 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs() .await; let mut resumed_builder = test_codex().with_home(home.clone()).with_config(|config| { - config.features.enable(Feature::Sqlite); - config.features.enable(Feature::MemoryTool); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MemoryTool) + .expect("test config should allow feature update"); config.memories.max_raw_memories_for_consolidation = 1; config.memories.no_memories_if_mcp_or_web_search = true; }); @@ -310,9 +322,16 @@ async fn web_search_pollution_moves_selected_thread_into_removed_phase2_inputs() } async fn build_test_codex(server: &wiremock::MockServer, home: Arc) -> Result { + #[allow(clippy::expect_used)] let mut builder = test_codex().with_home(home).with_config(|config| { - config.features.enable(Feature::Sqlite); - config.features.enable(Feature::MemoryTool); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MemoryTool) + .expect("test config should allow feature update"); config.memories.max_raw_memories_for_consolidation = 1; }); builder.build(server).await diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index e8fbcc851c..df02d5e258 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -133,7 +133,10 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let next_model = "exp-codex-personality"; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index a6a044f3f6..b02e3ae156 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -102,7 +102,10 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Pragmatic); }); let test = builder.build(&server).await?; @@ -332,7 +335,10 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul let mut resume_builder = test_codex().with_config(|config| { config.model = Some("gpt-5.2-codex".to_string()); - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Pragmatic); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 03f87aec14..2463b98de7 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -176,7 +176,10 @@ async fn process_sse_emits_failed_event_on_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -217,7 +220,10 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -278,7 +284,10 @@ async fn process_sse_failed_event_records_response_error_message() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -337,7 +346,10 @@ async fn process_sse_failed_event_logs_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -383,7 +395,10 @@ async fn process_sse_failed_event_logs_missing_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -438,7 +453,10 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -559,7 +577,10 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -625,7 +646,10 @@ async fn record_responses_sets_span_fields_for_response_events() { let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -706,7 +730,10 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -776,7 +803,10 @@ async fn handle_response_item_records_tool_result_for_function_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -856,7 +886,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await @@ -920,7 +953,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { - config.features.disable(Feature::GhostCommit); + config + .features + .disable(Feature::GhostCommit) + .expect("test config should allow feature update"); }) .build(&server) .await diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index f3a1f2e4db..548c09ba45 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -44,7 +44,10 @@ const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective so async fn personality_does_not_mutate_base_instructions_without_template() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Friendly); let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.1", &config); @@ -58,7 +61,10 @@ async fn personality_does_not_mutate_base_instructions_without_template() { async fn base_instructions_override_disables_personality_template() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Friendly); config.base_instructions = Some("override instructions".to_string()); @@ -81,7 +87,10 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -127,7 +136,10 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Friendly); }); let test = builder.build(&server).await?; @@ -181,7 +193,10 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::None); }); let test = builder.build(&server).await?; @@ -242,7 +257,10 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul let mut builder = test_codex() .with_model("gpt-5.2-codex") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -290,7 +308,10 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -388,7 +409,10 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Pragmatic); }); let test = builder.build(&server).await?; @@ -472,7 +496,10 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho async fn instructions_uses_base_if_feature_disabled() -> anyhow::Result<()> { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; - config.features.disable(Feature::Personality); + config + .features + .disable(Feature::Personality) + .expect("test config should allow feature update"); config.personality = Some(Personality::Friendly); let model_info = @@ -498,7 +525,10 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> let mut builder = test_codex() .with_model("exp-codex-personality") .with_config(|config| { - config.features.disable(Feature::Personality); + config + .features + .disable(Feature::Personality) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -642,7 +672,10 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.model = Some(remote_slug.to_string()); config.personality = Some(Personality::Friendly); }); @@ -757,7 +790,10 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - let mut builder = test_codex() .with_auth(codex_core::CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { - config.features.enable(Feature::Personality); + config + .features + .enable(Feature::Personality) + .expect("test config should allow feature update"); config.model = Some("gpt-5.2-codex".to_string()); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 12d81402c8..6850cb11ef 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -203,12 +203,19 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> { let codex_home = Arc::new(TempDir::new()?); write_plugin_app_plugin(codex_home.as_ref()); + #[allow(clippy::expect_used)] let mut builder = test_codex() .with_home(codex_home) .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(move |config| { - config.features.enable(Feature::Apps); - config.features.disable(Feature::AppsMcpGateway); + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::AppsMcpGateway) + .expect("test config should allow feature update"); config.chatgpt_base_url = apps_server.chatgpt_base_url; }); let codex = builder diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 0275f3b4b2..d8fd96ddfa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -125,7 +125,10 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .web_search_mode .set(WebSearchMode::Cached) .expect("test web_search_mode should satisfy constraints"); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; @@ -219,8 +222,14 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.disable(Feature::ApplyPatchFreeform); - config.features.enable(Feature::CollaborationModes); + config + .features + .disable(Feature::ApplyPatchFreeform) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); config.model = Some("gpt-5".to_string()); }) .build(&server) @@ -291,7 +300,10 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; @@ -379,7 +391,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; @@ -643,7 +658,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; @@ -767,7 +785,10 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; @@ -888,7 +909,10 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config.features.enable(Feature::CollaborationModes); + config + .features + .enable(Feature::CollaborationModes) + .expect("test config should allow feature update"); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 9942435048..7f8b996c08 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -30,7 +30,10 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| { - config.features.enable(Feature::EnableRequestCompression); + config + .features + .enable(Feature::EnableRequestCompression) + .expect("test config should allow feature update"); config.model_provider.base_url = Some(base_url); }); let codex = builder.build(&server).await?.codex; @@ -74,7 +77,10 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> let base_url = format!("{}/backend-api/codex/v1", server.uri()); let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::EnableRequestCompression); + config + .features + .enable(Feature::EnableRequestCompression) + .expect("test config should allow feature update"); config.model_provider.base_url = Some(base_url); }); let codex = builder.build(&server).await?.codex; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 6f898e8c03..6c495f3464 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -201,7 +201,10 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -280,7 +283,10 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -372,7 +378,10 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write() let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -462,7 +471,10 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write() let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -553,7 +565,10 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -651,7 +666,10 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); - config.features.enable(Feature::RequestPermissions); + config + .features + .enable(Feature::RequestPermissions) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index f767234c89..f66c2f209d 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -79,6 +79,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul let server = start_mock_server().await; let builder = test_codex(); + #[allow(clippy::expect_used)] let TestCodex { codex, cwd, @@ -87,7 +88,10 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul } = builder .with_config(move |config| { if mode == ModeKind::Default { - config.features.enable(Feature::DefaultModeRequestUserInput); + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .expect("test config should allow feature update"); } }) .build(&server) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d852c9cfb3..c55295f01c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -22,6 +22,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -142,8 +143,14 @@ fn configure_apps_with_optional_rmcp( apps_base_url: &str, rmcp_server_bin: Option, ) { - config.features.enable(Feature::Apps); - config.features.disable(Feature::AppsMcpGateway); + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::AppsMcpGateway) + .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url.to_string(); if let Some(command) = rmcp_server_bin { let mut servers = config.mcp_servers.get().clone(); @@ -181,13 +188,13 @@ async fn search_tool_flag_adds_tool() -> Result<()> { let server = start_mock_server().await; let apps_server = AppsTestServer::mount(&server).await?; - let mock = mount_sse_sequence( + let mock = mount_sse_once( &server, - vec![sse(vec![ + sse(vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "done"), ev_completed("resp-1"), - ])], + ]), ) .await; diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 38b1594b32..fe34630598 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -251,9 +251,13 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> { async fn unicode_output(login: bool) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); + #[allow(clippy::expect_used)] let harness = shell_command_harness_with(|builder| { builder.with_model("gpt-5.2").with_config(|config| { - config.features.enable(Feature::PowershellUtf8); + config + .features + .enable(Feature::PowershellUtf8) + .expect("test config should allow feature update"); }) }) .await?; @@ -281,9 +285,13 @@ async fn unicode_output(login: bool) -> anyhow::Result<()> { async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); + #[allow(clippy::expect_used)] let harness = shell_command_harness_with(|builder| { builder.with_model("gpt-5.2").with_config(|config| { - config.features.enable(Feature::PowershellUtf8); + config + .features + .enable(Feature::PowershellUtf8) + .expect("test config should allow feature update"); }) }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 59c13fa50d..491853f279 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -119,8 +119,14 @@ async fn run_snapshot_command_with_options( } = options; let builder = test_codex().with_config(move |config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -207,7 +213,10 @@ async fn run_shell_command_snapshot_with_options( shell_environment_set, } = options; let builder = test_codex().with_config(move |config| { - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); config.permissions.shell_environment_policy.r#set = shell_environment_set; }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -399,7 +408,10 @@ async fn linux_shell_command_uses_shell_snapshot() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Result<()> { let builder = test_codex().with_config(|config| { - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -446,8 +458,14 @@ async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Resu async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> { let builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -493,7 +511,10 @@ async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let builder = test_codex().with_config(|config| { - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); config.include_apply_patch_tool = true; }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -562,7 +583,10 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> { let builder = test_codex().with_config(|config| { - config.features.enable(Feature::ShellSnapshot); + config + .features + .enable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); }); let harness = TestCodexHarness::with_builder(builder).await?; let home = harness.test().home.clone(); diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 177453d6dd..b17219e5f1 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -40,7 +40,10 @@ use uuid::Uuid; async fn new_thread_is_recorded_in_state_db() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -170,7 +173,10 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { fs::write(&rollout_path, format!("{jsonl}\n")).expect("should write rollout file"); }) .with_config(|config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -230,7 +236,10 @@ async fn user_messages_persist_in_state_db() -> Result<()> { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; @@ -281,7 +290,10 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); config.memories.no_memories_if_mcp_or_web_search = true; }); let test = builder.build(&server).await?; @@ -331,7 +343,10 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_config(move |config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); config.memories.no_memories_if_mcp_or_web_search = true; let mut servers = config.mcp_servers.get().clone(); @@ -434,7 +449,10 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3dc463c4ab..5c154177a3 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -140,8 +140,12 @@ async fn setup_turn_one_with_spawned_child( ) .await; + #[allow(clippy::expect_used)] let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); }); let test = builder.build(server).await?; test.submit_turn(TURN_1_PROMPT).await?; @@ -252,7 +256,10 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> { .await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::Collab); + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ca666bce38..7e0ee338a4 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -284,7 +284,10 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ApplyPatchFreeform); + config + .features + .enable(Feature::ApplyPatchFreeform) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -389,7 +392,10 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::ApplyPatchFreeform); + config + .features + .enable(Feature::ApplyPatchFreeform) + .expect("test config should allow feature update"); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 9f1803c75e..6dd844595d 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -293,9 +293,15 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { let mut builder = test_codex().with_config(move |config| { if use_unified_exec { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); } else { - config.features.disable(Feature::UnifiedExec); + config + .features + .disable(Feature::UnifiedExec) + .expect("test config should allow feature update"); } }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/undo.rs b/codex-rs/core/tests/suite/undo.rs index e129a815bf..f059ece746 100644 --- a/codex-rs/core/tests/suite/undo.rs +++ b/codex-rs/core/tests/suite/undo.rs @@ -29,7 +29,10 @@ use pretty_assertions::assert_eq; async fn undo_harness() -> Result { let builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.include_apply_patch_tool = true; - config.features.enable(Feature::GhostCommit); + config + .features + .enable(Feature::GhostCommit) + .expect("test config should allow feature update"); }); TestCodexHarness::with_builder(builder).await } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index a3ef5711f8..d345153562 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -164,7 +164,10 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let builder = test_codex().with_config(|config| { config.include_apply_patch_tool = true; config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let harness = TestCodexHarness::with_builder(builder).await?; @@ -294,7 +297,10 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { let mut builder = test_codex().with_model("gpt-5").with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -370,7 +376,10 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { let mut builder = test_codex().with_model("gpt-5").with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -452,7 +461,10 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { let mut builder = test_codex().with_model("gpt-5").with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -535,7 +547,10 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -626,7 +641,10 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -702,7 +720,10 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -813,7 +834,10 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -917,7 +941,10 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1112,7 +1139,10 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1232,7 +1262,10 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1351,7 +1384,10 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1441,7 +1477,10 @@ async fn unified_exec_can_enable_tty() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1525,7 +1564,10 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1621,7 +1663,10 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1788,7 +1833,10 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1898,7 +1946,10 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -1994,7 +2045,10 @@ async fn unified_exec_interrupt_terminates_long_running_session() -> Result<()> let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2070,7 +2124,10 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2187,7 +2244,10 @@ async fn unified_exec_streams_after_lagged_output() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2322,7 +2382,10 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2432,7 +2495,10 @@ async fn unified_exec_formats_large_output_summary() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2521,7 +2587,10 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2605,7 +2674,10 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2724,7 +2796,10 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -2801,7 +2876,10 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; - config.features.enable(Feature::UnifiedExec); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs index a215ecbc06..9269ed262a 100644 --- a/codex-rs/core/tests/suite/unstable_features_warning.rs +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -19,7 +19,10 @@ use toml::toml; async fn emits_warning_when_unstable_features_enabled_via_config() { let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home).await; - config.features.enable(Feature::ChildAgentsMd); + config + .features + .enable(Feature::ChildAgentsMd) + .expect("test config should allow feature update"); let user_config_path = AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) .expect("absolute user config path"); @@ -56,7 +59,10 @@ async fn emits_warning_when_unstable_features_enabled_via_config() { async fn suppresses_warning_when_configured() { let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home).await; - config.features.enable(Feature::ChildAgentsMd); + config + .features + .enable(Feature::ChildAgentsMd) + .expect("test config should allow feature update"); config.suppress_unstable_features_warning = true; let user_config_path = AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 6ce43a2d13..8bd94ab9f1 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -250,7 +250,10 @@ async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyh let server = responses::start_mock_server().await; // Disable it to ease command matching. let mut builder = core_test_support::test_codex::test_codex().with_config(move |config| { - config.features.disable(Feature::ShellSnapshot); + config + .features + .disable(Feature::ShellSnapshot) + .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index b56e28d020..3e652577bb 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -299,7 +299,10 @@ async fn view_image_tool_can_preserve_original_resolution_on_gpt5_3_codex() -> a let mut builder = test_codex() .with_model("gpt-5.3-codex") .with_config(|config| { - config.features.enable(Feature::ImageDetailOriginal); + config + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -398,7 +401,10 @@ async fn view_image_tool_keeps_legacy_behavior_below_gpt5_3_codex() -> anyhow::R let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { - config.features.enable(Feature::ImageDetailOriginal); + config + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -499,7 +505,10 @@ async fn js_repl_emit_image_attaches_local_image() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::JsRepl); + config + .features + .enable(Feature::JsRepl) + .expect("test config should allow feature update"); }); let TestCodex { codex, @@ -613,8 +622,12 @@ async fn js_repl_view_image_requires_explicit_emit() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; + #[allow(clippy::expect_used)] let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::JsRepl); + config + .features + .enable(Feature::JsRepl) + .expect("test config should allow feature update"); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index e7082a832c..6df0338e92 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -74,7 +74,10 @@ async fn web_search_mode_takes_precedence_over_legacy_flags() { let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.features.enable(Feature::WebSearchRequest); + config + .features + .enable(Feature::WebSearchRequest) + .expect("test config should allow feature update"); config .web_search_mode .set(WebSearchMode::Cached) @@ -119,8 +122,14 @@ async fn web_search_mode_defaults_to_cached_when_features_disabled() { .web_search_mode .set(WebSearchMode::Cached) .expect("test web_search_mode should satisfy constraints"); - config.features.disable(Feature::WebSearchCached); - config.features.disable(Feature::WebSearchRequest); + config + .features + .disable(Feature::WebSearchCached) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WebSearchRequest) + .expect("test config should allow feature update"); }); let test = builder .build(&server) @@ -170,8 +179,14 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() { .web_search_mode .set(WebSearchMode::Cached) .expect("test web_search_mode should satisfy constraints"); - config.features.disable(Feature::WebSearchCached); - config.features.disable(Feature::WebSearchRequest); + config + .features + .disable(Feature::WebSearchCached) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WebSearchRequest) + .expect("test config should allow feature update"); }); let test = builder .build(&server) diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index fadd427c16..302ae3965b 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -45,7 +45,10 @@ async fn websocket_fallback_switches_to_http_on_upgrade_required_connect() -> Re move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); // If we don't treat 426 specially, the sampling loop would retry the WebSocket // handshake before switching to the HTTP transport. config.model_provider.stream_max_retries = Some(2); @@ -91,7 +94,10 @@ async fn websocket_fallback_switches_to_http_after_retries_exhausted() -> Result move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } @@ -136,7 +142,10 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } @@ -211,7 +220,10 @@ async fn websocket_fallback_is_sticky_across_turns() -> Result<()> { move |config| { config.model_provider.base_url = Some(base_url); config.model_provider.wire_api = codex_core::WireApi::Responses; - config.features.enable(Feature::ResponsesWebsockets); + config + .features + .enable(Feature::ResponsesWebsockets) + .expect("test config should allow feature update"); config.model_provider.stream_max_retries = Some(2); config.model_provider.request_max_retries = Some(0); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1fe9d0d499..dd20187508 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2835,25 +2835,31 @@ impl App { .with_profile(self.active_profile.as_deref()); for (feature, enabled) in &updates { let feature_key = feature.key(); - if *enabled { - // Update the in-memory configs. - self.config.features.enable(*feature); - self.chat_widget.set_feature_enabled(*feature, true); + if let Err(err) = self.config.features.set_enabled(*feature, *enabled) { + tracing::error!( + error = %err, + feature = feature_key, + "failed to update constrained feature flags" + ); + self.chat_widget.add_error_message(format!( + "Failed to update experimental feature `{feature_key}`: {err}" + )); + continue; + } + let effective_enabled = self.config.features.enabled(*feature); + self.chat_widget + .set_feature_enabled(*feature, effective_enabled); + if effective_enabled { builder = builder.set_feature_enabled(feature_key, true); + } else if feature.default_enabled() { + builder = builder.set_feature_enabled(feature_key, false); } else { - // Update the in-memory configs. - self.config.features.disable(*feature); - self.chat_widget.set_feature_enabled(*feature, false); - if feature.default_enabled() { - builder = builder.set_feature_enabled(feature_key, false); - } else { - // If the feature already default to `false`, we drop the key - // in the config file so that the user does not miss the feature - // once it gets globally released. - builder = builder.with_edits(vec![ConfigEdit::ClearPath { - segments: vec!["features".to_string(), feature_key.to_string()], - }]); - } + // If the feature already default to `false`, we drop the key + // in the config file so that the user does not miss the feature + // once it gets globally released. + builder = builder.with_edits(vec![ConfigEdit::ClearPath { + segments: vec!["features".to_string(), feature_key.to_string()], + }]); } } if windows_sandbox_changed { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0296bd08d2..e4e33e795a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6939,12 +6939,15 @@ impl ChatWidget { } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { - if enabled { - self.config.features.enable(feature); - } else { - self.config.features.disable(feature); + pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { + if let Err(err) = self.config.features.set_enabled(feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.key(), + "failed to update constrained chat widget feature state" + ); } + let enabled = self.config.features.enabled(feature); if feature == Feature::VoiceTranscription { self.bottom_pane.set_voice_transcription_enabled(enabled); } @@ -6985,6 +6988,7 @@ impl ChatWidget { ), ); } + enabled } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0ee7974d26..2861be570d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6059,7 +6059,10 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[tokio::test] async fn apps_popup_refreshes_when_connectors_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); let notion_id = "unit_test_apps_popup_refresh_connector_1"; let linear_id = "unit_test_apps_popup_refresh_connector_2"; @@ -6149,7 +6152,10 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { #[tokio::test] async fn apps_refresh_failure_keeps_existing_full_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); let notion_id = "unit_test_apps_refresh_failure_connector_1"; let linear_id = "unit_test_apps_refresh_failure_connector_2"; @@ -6228,7 +6234,10 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { #[tokio::test] async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetch() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); chat.connectors_prefetch_in_flight = true; chat.connectors_force_refetch_pending = true; @@ -6264,7 +6273,10 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc #[tokio::test] async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); let full_connectors = vec![ @@ -6360,7 +6372,10 @@ async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { #[tokio::test] async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); chat.on_connectors_loaded( @@ -6398,7 +6413,10 @@ async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { #[tokio::test] async fn apps_initial_load_applies_enabled_state_from_config() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); let temp = tempdir().expect("tempdir"); @@ -6447,7 +6465,10 @@ async fn apps_initial_load_applies_enabled_state_from_config() { #[tokio::test] async fn apps_refresh_preserves_toggled_enabled_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); chat.on_connectors_loaded( @@ -6512,7 +6533,10 @@ async fn apps_refresh_preserves_toggled_enabled_state() { #[tokio::test] async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.features.enable(Feature::Apps); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(true); chat.on_connectors_loaded( diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index a3e98d85c7..91588fd3d7 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -527,6 +527,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), + feature_requirements: None, mcp_servers: Some(BTreeMap::from([( "docs".to_string(), McpServerRequirement { @@ -652,6 +653,7 @@ approval_policy = "never" allowed_approval_policies: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), + feature_requirements: None, mcp_servers: None, rules: None, enforce_residency: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5d36ec441d..b9a1a5c47c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1501,7 +1501,10 @@ trust_level = "untrusted" async fn read_session_cwd_prefers_sqlite_when_thread_id_present() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let mut config = build_config(&temp_dir).await?; - config.features.enable(Feature::Sqlite); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); let thread_id = ThreadId::new(); let rollout_cwd = temp_dir.path().join("rollout-cwd"); From 2e154a35bc6df9239dff23b6552076fc2a8a49b1 Mon Sep 17 00:00:00 2001 From: gabec-openai Date: Wed, 4 Mar 2026 13:43:52 +0900 Subject: [PATCH 14/23] Add role-specific subagent nickname overrides (#13218) ## Summary - add `nickname_candidates` to agent role config - use role-specific nickname pools for spawned and resumed subagents - validate and schema-generate the new config surface ## Testing - `just fmt` - `just write-config-schema` - `just fix -p codex-core` - `cargo test -p codex-core` - `cargo test` --------- Co-authored-by: Codex --- codex-rs/core/config.schema.json | 7 + codex-rs/core/src/agent/control.rs | 76 +++++++++- codex-rs/core/src/agent/role.rs | 37 +++-- codex-rs/core/src/config/mod.rs | 222 +++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 749f6de23b..71d624d2c0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -20,6 +20,13 @@ "description": { "description": "Human-facing role documentation used in spawn tool guidance.", "type": "string" + }, + "nickname_candidates": { + "description": "Candidate nicknames for agents spawned with this role.", + "items": { + "type": "string" + }, + "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index fa643ff3ce..e7547f9c9d 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,5 +1,7 @@ use crate::agent::AgentStatus; use crate::agent::guards::Guards; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -32,7 +34,7 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, } -fn agent_nickname_list() -> Vec<&'static str> { +fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() .map(str::trim) @@ -40,6 +42,23 @@ fn agent_nickname_list() -> Vec<&'static str> { .collect() } +fn agent_nickname_candidates( + config: &crate::config::Config, + role_name: Option<&str>, +) -> Vec { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + if let Some(candidates) = + resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) + { + return candidates; + } + + default_agent_nickname_list() + .into_iter() + .map(ToOwned::to_owned) + .collect() +} + /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -94,7 +113,10 @@ impl AgentControl { agent_role, .. })) => { - let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?; + let candidate_names = agent_nickname_candidates(&config, agent_role.as_deref()); + let candidate_name_refs: Vec<&str> = + candidate_names.iter().map(String::as_str).collect(); + let agent_nickname = reservation.reserve_agent_nickname(&candidate_name_refs)?; Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, @@ -225,8 +247,12 @@ impl AgentControl { let reserved_agent_nickname = resumed_agent_nickname .as_deref() .map(|agent_nickname| { + let candidate_names = + agent_nickname_candidates(&config, resumed_agent_role.as_deref()); + let candidate_name_refs: Vec<&str> = + candidate_names.iter().map(String::as_str).collect(); reservation.reserve_agent_nickname_with_preference( - &agent_nickname_list(), + &candidate_name_refs, Some(agent_nickname), ) }) @@ -467,6 +493,7 @@ mod tests { use crate::CodexThread; use crate::ThreadManager; use crate::agent::agent_status_from_event; + use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigBuilder; use crate::config_loader::LoaderOverrides; @@ -1378,6 +1405,49 @@ mod tests { assert_eq!(agent_role, Some("explorer".to_string())); } + #[tokio::test] + async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { + let mut harness = AgentControlHarness::new().await; + harness.config.agent_roles.insert( + "researcher".to_string(), + AgentRoleConfig { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Atlas".to_string()]), + }, + ); + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("researcher".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) = + snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(agent_nickname, Some("Atlas".to_string())); + } + #[tokio::test] async fn resume_thread_subagent_restores_stored_nickname_and_role() { let (home, mut config) = test_config().await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 3ca496d6e1..0793febe54 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -38,15 +38,9 @@ pub(crate) async fn apply_role_to_config( role_name: Option<&str>, ) -> Result<(), String> { let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); - let (config_file, is_built_in) = config - .agent_roles - .get(role_name) - .map(|role| (&role.config_file, false)) - .or_else(|| { - built_in::configs() - .get(role_name) - .map(|role| (&role.config_file, true)) - }) + let is_built_in = !config.agent_roles.contains_key(role_name); + let (config_file, is_built_in) = resolve_role_config(config, role_name) + .map(|role| (&role.config_file, is_built_in)) .ok_or_else(|| format!("unknown agent_type '{role_name}'"))?; let Some(config_file) = config_file.as_ref() else { return Ok(()); @@ -139,6 +133,16 @@ pub(crate) async fn apply_role_to_config( Ok(()) } +pub(crate) fn resolve_role_config<'a>( + config: &'a Config, + role_name: &str, +) -> Option<&'a AgentRoleConfig> { + config + .agent_roles + .get(role_name) + .or_else(|| built_in::configs().get(role_name)) +} + pub(crate) mod spawn_tool_spec { use super::*; @@ -196,6 +200,7 @@ mod built_in { AgentRoleConfig { description: Some("Default agent.".to_string()), config_file: None, + nickname_candidates: None, } ), ( @@ -210,6 +215,7 @@ Rules: - Run explorers in parallel when useful. - Reuse existing explorers for related questions."#.to_string()), config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), + nickname_candidates: None, } ), ( @@ -224,6 +230,7 @@ Rules: - Explicitly assign **ownership** of the task (files / responsibility). - Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."#.to_string()), config_file: None, + nickname_candidates: None, } ), // Awaiter is temp removed @@ -354,6 +361,7 @@ mod tests { AgentRoleConfig { description: None, config_file: Some(PathBuf::from("/path/does/not/exist.toml")), + nickname_candidates: None, }, ); @@ -373,6 +381,7 @@ mod tests { AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -403,6 +412,7 @@ mod tests { AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -456,6 +466,7 @@ model_provider = "test-provider" AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -512,6 +523,7 @@ model_provider = "role-provider" AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -569,6 +581,7 @@ model_provider = "base-provider" AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -630,6 +643,7 @@ model_reasoning_effort = "high" AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -671,6 +685,7 @@ writable_roots = ["./sandbox-root"] AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -724,6 +739,7 @@ writable_roots = ["./sandbox-root"] AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -764,6 +780,7 @@ enabled = false AgentRoleConfig { description: None, config_file: Some(role_path), + nickname_candidates: None, }, ); @@ -791,6 +808,7 @@ enabled = false AgentRoleConfig { description: Some("user override".to_string()), config_file: None, + nickname_candidates: None, }, ), ("researcher".to_string(), AgentRoleConfig::default()), @@ -811,6 +829,7 @@ enabled = false AgentRoleConfig { description: Some("first".to_string()), config_file: None, + nickname_candidates: None, }, )]); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b71f8fac4..2d833d8b51 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -78,6 +78,7 @@ use serde::Deserialize; use serde::Serialize; use similar::DiffableStr; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; @@ -1385,6 +1386,7 @@ pub struct AgentsToml { /// [agents.researcher] /// description = "Research-focused role." /// config_file = "./agents/researcher.toml" + /// nickname_candidates = ["Herodotus", "Ibn Battuta"] /// ``` #[serde(default, flatten)] pub roles: BTreeMap, @@ -1396,6 +1398,8 @@ pub struct AgentRoleConfig { pub description: Option, /// Path to a role-specific config layer. pub config_file: Option, + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -1407,6 +1411,9 @@ pub struct AgentRoleToml { /// Path to a role-specific config layer. /// Relative paths are resolved relative to the `config.toml` that defines them. pub config_file: Option, + + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, } impl From for Tools { @@ -1895,11 +1902,16 @@ impl Config { let config_file = role.config_file.as_ref().map(AbsolutePathBuf::to_path_buf); Self::validate_agent_role_config_file(name, config_file.as_deref())?; + let nickname_candidates = Self::normalize_agent_role_nickname_candidates( + name, + role.nickname_candidates.as_deref(), + )?; Ok(( name.clone(), AgentRoleConfig { description: role.description.clone(), config_file, + nickname_candidates, }, )) }) @@ -2361,6 +2373,58 @@ impl Config { } } + fn normalize_agent_role_nickname_candidates( + role_name: &str, + nickname_candidates: Option<&[String]>, + ) -> std::io::Result>> { + let Some(nickname_candidates) = nickname_candidates else { + return Ok(None); + }; + + if nickname_candidates.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("agents.{role_name}.nickname_candidates must contain at least one name"), + )); + } + + let mut normalized_candidates = Vec::with_capacity(nickname_candidates.len()); + let mut seen_candidates = BTreeSet::new(); + + for nickname in nickname_candidates { + let normalized_nickname = nickname.trim(); + if normalized_nickname.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("agents.{role_name}.nickname_candidates cannot contain blank names"), + )); + } + + if !seen_candidates.insert(normalized_nickname.to_owned()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("agents.{role_name}.nickname_candidates cannot contain duplicates"), + )); + } + + if !normalized_nickname + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "agents.{role_name}.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores" + ), + )); + } + + normalized_candidates.push(normalized_nickname.to_owned()); + } + + Ok(Some(normalized_candidates)) + } + pub fn set_windows_sandbox_enabled(&mut self, value: bool) { self.permissions.windows_sandbox_mode = if value { Some(WindowsSandboxModeToml::Unelevated) @@ -4662,6 +4726,7 @@ model = "gpt-5.1-codex" AgentRoleToml { description: Some("Research role".to_string()), config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?), + nickname_candidates: None, }, )]), }), @@ -4698,6 +4763,7 @@ model = "gpt-5.1-codex" r#"[agents.researcher] description = "Research role" config_file = "./agents/researcher.toml" +nickname_candidates = ["Hypatia", "Noether"] "#, ) .await?; @@ -4714,6 +4780,162 @@ config_file = "./agents/researcher.toml" .and_then(|role| role.config_file.as_ref()), Some(&role_config_path) ); + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.nickname_candidates.as_ref()) + .map(|candidates| candidates.iter().map(String::as_str).collect::>()), + Some(vec!["Hypatia", "Noether"]) + ); + + Ok(()) + } + + #[test] + fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec![ + " Hypatia ".to_string(), + "Noether".to_string(), + ]), + }, + )]), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.nickname_candidates.as_ref()) + .map(|candidates| candidates.iter().map(String::as_str).collect::>()), + Some(vec!["Hypatia", "Noether"]) + ); + + Ok(()) + } + + #[test] + fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(Vec::new()), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("empty nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("agents.researcher.nickname_candidates") + ); + + Ok(()) + } + + #[test] + fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec![ + "Hypatia".to_string(), + " Hypatia ".to_string(), + ]), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("duplicate nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("agents.researcher.nickname_candidates cannot contain duplicates") + ); + + Ok(()) + } + + #[test] + fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + max_threads: None, + max_depth: None, + job_max_runtime_seconds: None, + roles: BTreeMap::from([( + "researcher".to_string(), + AgentRoleToml { + description: Some("Research role".to_string()), + config_file: None, + nickname_candidates: Some(vec!["Agent ".to_string()]), + }, + )]), + }), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + let err = result.expect_err("unsafe nickname candidates should be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!(err.to_string().contains( + "agents.researcher.nickname_candidates may only contain ASCII letters, digits, spaces, hyphens, and underscores" + )); Ok(()) } From d622bff384b8781133ba3a30ff9b5c8b15f36fb5 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 3 Mar 2026 20:57:29 -0800 Subject: [PATCH 15/23] chore: Nest skill and protocol network permissions under `network.enabled` (#13427) ## Summary Changes the permission profile shape from a bare network boolean to a nested object. Before: ```yaml permissions: network: true ``` After: ```yaml permissions: network: enabled: true ``` This also updates the shared Rust and app-server protocol types so `PermissionProfile.network` is no longer `Option`, but `Option` with `enabled: Option`. ## What Changed - Updated `PermissionProfile` in `codex-rs/protocol/src/models.rs`: - `pub network: Option` -> `pub network: Option` - Added `NetworkPermissions` with: - `pub enabled: Option` - Changed emptiness semantics so `network` is only considered empty when `enabled` is `None` - Updated skill metadata parsing to accept `permissions.network.enabled` - Updated core permission consumers to read `network.enabled.unwrap_or(false)` where a concrete boolean is needed - Updated app-server v2 protocol types and regenerated schema/TypeScript outputs - Updated docs to mention `additionalPermissions.network.enabled` --- ...CommandExecutionRequestApprovalParams.json | 21 ++++++++-- .../schema/json/EventMsg.json | 21 ++++++++-- .../schema/json/ServerRequest.json | 21 ++++++++-- .../codex_app_server_protocol.schemas.json | 42 ++++++++++++++++--- .../codex_app_server_protocol.v2.schemas.json | 21 ++++++++-- .../schema/typescript/NetworkPermissions.ts | 5 +++ .../schema/typescript/PermissionProfile.ts | 3 +- .../schema/typescript/index.ts | 1 + .../v2/AdditionalNetworkPermissions.ts | 5 +++ .../v2/AdditionalPermissionProfile.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 20 ++++++++- codex-rs/app-server/README.md | 2 +- codex-rs/core/src/sandboxing/mod.rs | 23 ++++++++-- codex-rs/core/src/skills/loader.rs | 9 +++- codex-rs/core/src/skills/permissions.rs | 15 +++++-- codex-rs/protocol/src/models.rs | 18 +++++++- .../src/unix/escalate_server.rs | 9 +++- .../tui/src/bottom_pane/approval_overlay.rs | 16 +++++-- 19 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/NetworkPermissions.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 5a19a39281..891946fd94 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -65,6 +65,17 @@ }, "type": "object" }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AdditionalPermissionProfile": { "properties": { "fileSystem": { @@ -88,9 +99,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } ] } }, diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index a22962cd40..cafd8bc5d0 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -3888,6 +3888,17 @@ ], "type": "string" }, + "NetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "NetworkPolicyAmendment": { "properties": { "action": { @@ -4052,9 +4063,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/NetworkPermissions" + }, + { + "type": "null" + } ] } }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 9138440548..fce6ec6190 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -65,6 +65,17 @@ }, "type": "object" }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AdditionalPermissionProfile": { "properties": { "fileSystem": { @@ -88,9 +99,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } ] } }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 374bdc1971..94e9f3a50c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -61,6 +61,17 @@ }, "type": "object" }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "AdditionalPermissionProfile": { "properties": { "fileSystem": { @@ -84,9 +95,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } ] } }, @@ -5272,6 +5287,17 @@ ], "type": "string" }, + "NetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "NetworkPolicyAmendment": { "properties": { "action": { @@ -5428,9 +5454,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/NetworkPermissions" + }, + { + "type": "null" + } ] } }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2ca096cff1..0d1de74481 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7824,6 +7824,17 @@ ], "type": "string" }, + "NetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "NetworkPolicyAmendment": { "properties": { "action": { @@ -8148,9 +8159,13 @@ ] }, "network": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/NetworkPermissions" + }, + { + "type": "null" + } ] } }, diff --git a/codex-rs/app-server-protocol/schema/typescript/NetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/NetworkPermissions.ts new file mode 100644 index 0000000000..7fb197b0e7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/NetworkPermissions.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkPermissions = { enabled: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts index bfc0de27b7..c9a60f067c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PermissionProfile.ts @@ -3,5 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { FileSystemPermissions } from "./FileSystemPermissions"; import type { MacOsPermissions } from "./MacOsPermissions"; +import type { NetworkPermissions } from "./NetworkPermissions"; -export type PermissionProfile = { network: boolean | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, }; +export type PermissionProfile = { network: NetworkPermissions | null, file_system: FileSystemPermissions | null, macos: MacOsPermissions | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 228998cddb..faad506dfc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -115,6 +115,7 @@ export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +export type { NetworkPermissions } from "./NetworkPermissions"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts new file mode 100644 index 0000000000..823de26ca3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalNetworkPermissions.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AdditionalNetworkPermissions = { enabled: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts index 4ca4dca120..701ba54316 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts @@ -3,5 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; +import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; -export type AdditionalPermissionProfile = { network: boolean | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, }; +export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 02bf51727d..0c9b2dc587 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -6,6 +6,7 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; +export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7c69d9fa11..2147295c9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -31,6 +31,7 @@ use codex_protocol::models::MacOsAutomationValue as CoreMacOsAutomationValue; use codex_protocol::models::MacOsPermissions as CoreMacOsPermissions; use codex_protocol::models::MacOsPreferencesValue as CoreMacOsPreferencesValue; use codex_protocol::models::MessagePhase; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; @@ -852,11 +853,26 @@ impl From for AdditionalMacOsPermissions { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalNetworkPermissions { + pub enabled: Option, +} + +impl From for AdditionalNetworkPermissions { + fn from(value: CoreNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct AdditionalPermissionProfile { - pub network: Option, + pub network: Option, pub file_system: Option, pub macos: Option, } @@ -864,7 +880,7 @@ pub struct AdditionalPermissionProfile { impl From for AdditionalPermissionProfile { fn from(value: CorePermissionProfile) -> Self { Self { - network: value.network, + network: value.network.map(AdditionalNetworkPermissions::from), file_system: value.file_system.map(AdditionalFileSystemPermissions::from), macos: value.macos.map(AdditionalMacOsPermissions::from), } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b105d411b1..658b2e08d8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -742,7 +742,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. 3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index fe7d077bb8..10c5d71da2 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -193,7 +193,12 @@ fn merge_network_access( base_network_access: bool, additional_permissions: &PermissionProfile, ) -> bool { - base_network_access || matches!(additional_permissions.network, Some(true)) + base_network_access + || additional_permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) } fn sandbox_policy_with_additional_permissions( @@ -431,6 +436,7 @@ mod tests { use crate::tools::sandboxing::SandboxablePreference; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; + use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; @@ -470,7 +476,9 @@ mod tests { ) .expect("absolute temp dir"); let permissions = normalize_additional_permissions(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![path.clone()]), write: Some(vec![path.clone()]), @@ -479,7 +487,12 @@ mod tests { }) .expect("permissions"); - assert_eq!(permissions.network, Some(true)); + assert_eq!( + permissions.network, + Some(NetworkPermissions { + enabled: Some(true), + }) + ); assert_eq!( permissions.file_system, Some(FileSystemPermissions { @@ -505,7 +518,9 @@ mod tests { network_access: false, }, &PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![path.clone()]), write: Some(Vec::new()), diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index bfa22cf01b..751fa07200 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -32,6 +32,8 @@ use tracing::error; #[cfg(test)] use crate::config::Config; +#[cfg(test)] +use codex_protocol::models::NetworkPermissions; #[derive(Debug, Deserialize)] struct SkillFrontmatter { @@ -1387,7 +1389,8 @@ policy: {} skill_dir, r#" permissions: - network: true + network: + enabled: true file_system: read: - "./data" @@ -1408,7 +1411,9 @@ permissions: assert_eq!( outcome.skills[0].permission_profile, Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![ AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path())) diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 825128307d..53b1f7bd93 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -44,7 +44,7 @@ pub(crate) fn compile_permission_profile( file_system, macos, } = permissions?; - let network_access = network.unwrap_or_default(); + let network_access = network.and_then(|value| value.enabled).unwrap_or_default(); let file_system = file_system.unwrap_or_default(); let fs_read = normalize_permission_paths( file_system.read.as_deref().unwrap_or_default(), @@ -232,6 +232,7 @@ mod tests { use codex_protocol::models::MacOsPermissions; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsPreferencesValue; + use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -251,7 +252,9 @@ mod tests { fs::create_dir_all(&read_dir).expect("read dir"); let profile = compile_permission_profile(Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![ absolute_path(&skill_dir.join("data")), @@ -318,7 +321,9 @@ mod tests { fs::create_dir_all(&skill_dir).expect("skill dir"); let profile = compile_permission_profile(Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), ..Default::default() })) .expect("profile"); @@ -353,7 +358,9 @@ mod tests { fs::create_dir_all(&read_dir).expect("read dir"); let profile = compile_permission_profile(Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![absolute_path(&skill_dir.join("data"))]), write: Some(Vec::new()), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8be7c9abf3..d12eef3b30 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -84,6 +84,17 @@ impl MacOsPermissions { } } +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +pub struct NetworkPermissions { + pub enabled: Option, +} + +impl NetworkPermissions { + pub fn is_empty(&self) -> bool { + self.enabled.is_none() + } +} + #[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(untagged)] pub enum MacOsPreferencesValue { @@ -126,14 +137,17 @@ pub struct MacOsSeatbeltProfileExtensions { #[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] pub struct PermissionProfile { - pub network: Option, + pub network: Option, pub file_system: Option, pub macos: Option, } impl PermissionProfile { pub fn is_empty(&self) -> bool { - self.network.is_none() + self.network + .as_ref() + .map(NetworkPermissions::is_empty) + .unwrap_or(true) && self .file_system .as_ref() diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index bb45c3ed71..92d9adb9d3 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -277,6 +277,7 @@ async fn handle_escalate_session_with_policy( mod tests { use super::*; use codex_protocol::approvals::EscalationPermissions; + use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -513,14 +514,18 @@ mod tests { Arc::new(DeterministicEscalationPolicy { decision: EscalationDecision::escalate(EscalationExecution::Permissions( EscalationPermissions::PermissionProfile(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), ..Default::default() }), )), }), Arc::new(PermissionAssertingShellCommandExecutor { expected_permissions: EscalationPermissions::PermissionProfile(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), ..Default::default() }), }), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 548be75769..21f0261cf1 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -641,7 +641,12 @@ fn format_additional_permissions_rule( additional_permissions: &PermissionProfile, ) -> Option { let mut parts = Vec::new(); - if matches!(additional_permissions.network, Some(true)) { + if additional_permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { parts.push("network".to_string()); } if let Some(file_system) = additional_permissions.file_system.as_ref() { @@ -721,6 +726,7 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_protocol::models::FileSystemPermissions; + use codex_protocol::models::NetworkPermissions; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::NetworkPolicyAmendment; @@ -1077,7 +1083,9 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), @@ -1123,7 +1131,9 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: Some(PermissionProfile { - network: Some(true), + network: Some(NetworkPermissions { + enabled: Some(true), + }), file_system: Some(FileSystemPermissions { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), From 7134220f3c1a5119cc0ea775b1807fe186b8a614 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 3 Mar 2026 21:44:52 -0800 Subject: [PATCH 16/23] core: box wrapper futures to reduce stack pressure (#13429) Follow-up to [#13388](https://github.com/openai/codex/pull/13388). This uses the same general fix pattern as [#12421](https://github.com/openai/codex/pull/12421), but in the `codex-core` compact/resume/fork path. ## Why `compact_resume_after_second_compaction_preserves_history` started overflowing the stack on Windows CI after `#13388`. The important part is that this was not a compaction-recursion bug. The test exercises a path with several thin `async fn` wrappers around much larger thread-spawn, resume, and fork futures. When one `async fn` awaits another inline, the outer future stores the callee future as part of its own state machine. In a long wrapper chain, that means a caller can accidentally inline a lot more state than the source code suggests. That is exactly what was happening here: - `ThreadManager` convenience methods such as `start_thread`, `resume_thread_from_rollout`, and `fork_thread` were inlining the larger spawn/resume futures beneath them. - `core_test_support::test_codex` added another wrapper layer on top of those same paths. - `compact_resume_fork` adds a few more helpers, and this particular test drives the resume/fork path multiple times. On Windows, that was enough to push both the libtest thread and Tokio worker threads over the edge. The previous 8 MiB test-thread workaround proved the failure was stack-related, but it did not address the underlying future size. ## How This Was Debugged The useful debugging pattern here was to turn the CI-only failure into a local low-stack repro. 1. First, remove the explicit large-stack harness so the test runs on the normal `#[tokio::test]` path. 2. Build the test binary normally. 3. Re-run the already-built `tests/all` binary directly with progressively smaller `RUST_MIN_STACK` values. Running the built binary directly matters: it keeps the reduced stack size focused on the test process instead of also applying it to `cargo` and `rustc`. That made it possible to answer two questions quickly: - Does the failure still reproduce without the workaround? Yes. - Does boxing the wrapper futures actually buy back stack headroom? Also yes. After this change, the built test binary passes with `RUST_MIN_STACK=917504` and still overflows at `786432`, which is enough evidence to justify removing the explicit 8 MiB override while keeping a deterministic low-stack repro for future debugging. If we hit a similar issue again, the first places to inspect are thin `async fn` wrappers that mostly forward into a much larger async implementation. ## `Box::pin()` Primer `async fn` compiles into a state machine. If a wrapper does this: ```rust async fn wrapper() { inner().await; } ``` then `wrapper()` stores the full `inner()` future inline as part of its own state. If the wrapper instead does this: ```rust async fn wrapper() { Box::pin(inner()).await; } ``` then the child future lives on the heap, and the outer future only stores a pinned pointer to it. That usually trades one allocation for a substantially smaller outer future, which is exactly the tradeoff we want when the problem is stack pressure rather than raw CPU time. Useful references: - [`Box::pin`](https://doc.rust-lang.org/std/boxed/struct.Box.html#method.pin) - [Async book: Pinning](https://rust-lang.github.io/async-book/04_pinning/01_chapter.html) ## What Changed - Boxed the wrapper futures in `core/src/thread_manager.rs` around `start_thread`, `resume_thread_from_rollout`, `fork_thread`, and the corresponding `ThreadManagerState` spawn helpers so callers no longer inline the full spawn/resume state machine through multiple layers. - Boxed the matching test-only wrapper futures in `core/tests/common/test_codex.rs` and `core/tests/suite/compact_resume_fork.rs`, which sit directly on top of the same path. - Restored `compact_resume_after_second_compaction_preserves_history` in `core/tests/suite/compact_resume_fork.rs` to a normal `#[tokio::test]` and removed the explicit `TEST_STACK_SIZE_BYTES` thread/runtime sizing. - Simplified a tiny helper in `compact_resume_fork` by making `fetch_conversation_path()` synchronous, which removes one more unnecessary future layer from the test path. ## Verification - `cargo test -p codex-core --test all suite::compact_resume_fork::compact_resume_after_second_compaction_preserves_history -- --exact --nocapture` - `cargo test -p codex-core --test all suite::compact_resume_fork -- --nocapture` - Re-ran the built `codex-core` `tests/all` binary directly with reduced stack sizes: - `RUST_MIN_STACK=917504` passes - `RUST_MIN_STACK=786432` still overflows - `cargo test -p codex-core` - Still fails locally in unrelated existing integration areas that expect the `codex` / `test_stdio_server` binaries or hit the existing `search_tool` wiremock mismatches. --- codex-rs/core/src/thread_manager.rs | 94 +++++++++---------- codex-rs/core/tests/common/test_codex.rs | 25 ++--- .../core/tests/suite/compact_resume_fork.rs | 49 +++------- 3 files changed, 72 insertions(+), 96 deletions(-) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2723ad955c..68ccae1776 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -312,8 +312,9 @@ impl ThreadManager { } pub async fn start_thread(&self, config: Config) -> CodexResult { - self.start_thread_with_tools(config, Vec::new(), false) - .await + // Box delegated thread-spawn futures so these convenience wrappers do + // not inline the full spawn path into every caller's async state. + Box::pin(self.start_thread_with_tools(config, Vec::new(), false)).await } pub async fn start_thread_with_tools( @@ -322,12 +323,12 @@ impl ThreadManager { dynamic_tools: Vec, persist_extended_history: bool, ) -> CodexResult { - self.start_thread_with_tools_and_service_name( + Box::pin(self.start_thread_with_tools_and_service_name( config, dynamic_tools, persist_extended_history, None, - ) + )) .await } @@ -338,17 +339,16 @@ impl ThreadManager { persist_extended_history: bool, metrics_service_name: Option, ) -> CodexResult { - self.state - .spawn_thread( - config, - InitialHistory::New, - Arc::clone(&self.state.auth_manager), - self.agent_control(), - dynamic_tools, - persist_extended_history, - metrics_service_name, - ) - .await + Box::pin(self.state.spawn_thread( + config, + InitialHistory::New, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + dynamic_tools, + persist_extended_history, + metrics_service_name, + )) + .await } pub async fn resume_thread_from_rollout( @@ -358,7 +358,7 @@ impl ThreadManager { auth_manager: Arc, ) -> CodexResult { let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; - self.resume_thread_with_history(config, initial_history, auth_manager, false) + Box::pin(self.resume_thread_with_history(config, initial_history, auth_manager, false)) .await } @@ -369,17 +369,16 @@ impl ThreadManager { auth_manager: Arc, persist_extended_history: bool, ) -> CodexResult { - self.state - .spawn_thread( - config, - initial_history, - auth_manager, - self.agent_control(), - Vec::new(), - persist_extended_history, - None, - ) - .await + Box::pin(self.state.spawn_thread( + config, + initial_history, + auth_manager, + self.agent_control(), + Vec::new(), + persist_extended_history, + None, + )) + .await } /// Removes the thread from the manager's internal map, though the thread is stored @@ -411,17 +410,16 @@ impl ThreadManager { ) -> CodexResult { let history = RolloutRecorder::get_rollout_history(&path).await?; let history = truncate_before_nth_user_message(history, nth_user_message); - self.state - .spawn_thread( - config, - history, - Arc::clone(&self.state.auth_manager), - self.agent_control(), - Vec::new(), - persist_extended_history, - None, - ) - .await + Box::pin(self.state.spawn_thread( + config, + history, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + Vec::new(), + persist_extended_history, + None, + )) + .await } pub(crate) fn agent_control(&self) -> AgentControl { @@ -474,14 +472,14 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, ) -> CodexResult { - self.spawn_new_thread_with_source( + Box::pin(self.spawn_new_thread_with_source( config, agent_control, self.session_source.clone(), false, None, None, - ) + )) .await } @@ -494,7 +492,7 @@ impl ThreadManagerState { metrics_service_name: Option, inherited_shell_snapshot: Option>, ) -> CodexResult { - self.spawn_thread_with_source( + Box::pin(self.spawn_thread_with_source( config, InitialHistory::New, Arc::clone(&self.auth_manager), @@ -504,7 +502,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, inherited_shell_snapshot, - ) + )) .await } @@ -517,7 +515,7 @@ impl ThreadManagerState { inherited_shell_snapshot: Option>, ) -> CodexResult { let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; - self.spawn_thread_with_source( + Box::pin(self.spawn_thread_with_source( config, initial_history, Arc::clone(&self.auth_manager), @@ -527,7 +525,7 @@ impl ThreadManagerState { false, None, inherited_shell_snapshot, - ) + )) .await } @@ -540,7 +538,7 @@ impl ThreadManagerState { persist_extended_history: bool, inherited_shell_snapshot: Option>, ) -> CodexResult { - self.spawn_thread_with_source( + Box::pin(self.spawn_thread_with_source( config, initial_history, Arc::clone(&self.auth_manager), @@ -550,7 +548,7 @@ impl ThreadManagerState { persist_extended_history, None, inherited_shell_snapshot, - ) + )) .await } @@ -566,7 +564,7 @@ impl ThreadManagerState { persist_extended_history: bool, metrics_service_name: Option, ) -> CodexResult { - self.spawn_thread_with_source( + Box::pin(self.spawn_thread_with_source( config, initial_history, auth_manager, @@ -576,7 +574,7 @@ impl ThreadManagerState { persist_extended_history, metrics_service_name, None, - ) + )) .await } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a79838df00..7bd5763d19 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -105,7 +105,7 @@ impl TestCodexBuilder { Some(home) => home, None => Arc::new(TempDir::new()?), }; - self.build_with_home(server, home, None).await + Box::pin(self.build_with_home(server, home, None)).await } pub async fn build_with_streaming_server( @@ -117,8 +117,7 @@ impl TestCodexBuilder { Some(home) => home, None => Arc::new(TempDir::new()?), }; - self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None) - .await + Box::pin(self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None)).await } pub async fn build_with_websocket_server( @@ -139,8 +138,7 @@ impl TestCodexBuilder { .enable(Feature::ResponsesWebsockets) .expect("test config should allow feature update"); })); - self.build_with_home_and_base_url(base_url, home, None) - .await + Box::pin(self.build_with_home_and_base_url(base_url, home, None)).await } pub async fn resume( @@ -149,7 +147,7 @@ impl TestCodexBuilder { home: Arc, rollout_path: PathBuf, ) -> anyhow::Result { - self.build_with_home(server, home, Some(rollout_path)).await + Box::pin(self.build_with_home(server, home, Some(rollout_path))).await } async fn build_with_home( @@ -160,7 +158,7 @@ impl TestCodexBuilder { ) -> anyhow::Result { let base_url = format!("{}/v1", server.uri()); let (config, cwd) = self.prepare_config(base_url, &home).await?; - self.build_from_config(config, cwd, home, resume_from).await + Box::pin(self.build_from_config(config, cwd, home, resume_from)).await } async fn build_with_home_and_base_url( @@ -170,7 +168,7 @@ impl TestCodexBuilder { resume_from: Option, ) -> anyhow::Result { let (config, cwd) = self.prepare_config(base_url, &home).await?; - self.build_from_config(config, cwd, home, resume_from).await + Box::pin(self.build_from_config(config, cwd, home, resume_from)).await } async fn build_from_config( @@ -201,11 +199,14 @@ impl TestCodexBuilder { let new_conversation = match resume_from { Some(path) => { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth); - thread_manager - .resume_thread_from_rollout(config.clone(), path, auth_manager) - .await? + Box::pin(thread_manager.resume_thread_from_rollout( + config.clone(), + path, + auth_manager, + )) + .await? } - None => thread_manager.start_thread(config.clone()).await?, + None => Box::pin(thread_manager.start_thread(config.clone())).await?, }; Ok(TestCodex { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fe536077a3..be79c11708 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -157,7 +157,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { user_turn(&base, "hello world").await; compact_conversation(&base).await; user_turn(&base, "AFTER_COMPACT").await; - let base_path = fetch_conversation_path(&base).await; + let base_path = fetch_conversation_path(&base); assert!( base_path.exists(), "compact+resume test expects base path {base_path:?} to exist", @@ -165,7 +165,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { let resumed = resume_conversation(&manager, &config, base_path).await; user_turn(&resumed, "AFTER_RESUME").await; - let resumed_path = fetch_conversation_path(&resumed).await; + let resumed_path = fetch_conversation_path(&resumed); assert!( resumed_path.exists(), "compact+resume test expects resumed path {resumed_path:?} to exist", @@ -292,33 +292,10 @@ async fn compact_resume_and_fork_preserve_model_history_view() { assert_eq!(requests.len(), 5); } -#[test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] /// Scenario: after the forked branch is compacted, resuming again should reuse /// the compacted history and only append the new user message. -fn compact_resume_after_second_compaction_preserves_history() -> Result<()> { - const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; - - let handle = std::thread::Builder::new() - .name("compact_resume_after_second_compaction_preserves_history".to_string()) - .stack_size(TEST_STACK_SIZE_BYTES) - .spawn(|| -> Result<()> { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .thread_stack_size(TEST_STACK_SIZE_BYTES) - .enable_all() - .build()?; - runtime.block_on(compact_resume_after_second_compaction_preserves_history_impl()) - })?; - - match handle.join() { - Ok(result) => result, - Err(_) => Err(anyhow::anyhow!( - "compact_resume_after_second_compaction_preserves_history thread panicked" - )), - } -} - -async fn compact_resume_after_second_compaction_preserves_history_impl() -> Result<()> { +async fn compact_resume_after_second_compaction_preserves_history() -> Result<()> { if network_disabled() { println!("Skipping test because network is disabled in this sandbox"); return Ok(()); @@ -335,7 +312,7 @@ async fn compact_resume_after_second_compaction_preserves_history_impl() -> Resu user_turn(&base, "hello world").await; compact_conversation(&base).await; user_turn(&base, "AFTER_COMPACT").await; - let base_path = fetch_conversation_path(&base).await; + let base_path = fetch_conversation_path(&base); assert!( base_path.exists(), "second compact test expects base path {base_path:?} to exist", @@ -343,7 +320,7 @@ async fn compact_resume_after_second_compaction_preserves_history_impl() -> Resu let resumed = resume_conversation(&manager, &config, base_path).await; user_turn(&resumed, "AFTER_RESUME").await; - let resumed_path = fetch_conversation_path(&resumed).await; + let resumed_path = fetch_conversation_path(&resumed); assert!( resumed_path.exists(), "second compact test expects resumed path {resumed_path:?} to exist", @@ -354,7 +331,7 @@ async fn compact_resume_after_second_compaction_preserves_history_impl() -> Resu compact_conversation(&forked).await; user_turn(&forked, "AFTER_COMPACT_2").await; - let forked_path = fetch_conversation_path(&forked).await; + let forked_path = fetch_conversation_path(&forked); assert!( forked_path.exists(), "second compact test expects forked path {forked_path:?} to exist", @@ -558,7 +535,9 @@ async fn start_test_conversation( config.model = Some(model); } }); - let test = builder.build(server).await.expect("create conversation"); + let test = Box::pin(builder.build(server)) + .await + .expect("create conversation"); (test.home, test.config, test.thread_manager, test.codex) } @@ -595,7 +574,7 @@ async fn compact_conversation(conversation: &Arc) { wait_for_event(conversation, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; } -async fn fetch_conversation_path(conversation: &Arc) -> std::path::PathBuf { +fn fetch_conversation_path(conversation: &Arc) -> std::path::PathBuf { conversation.rollout_path().expect("rollout path") } @@ -607,8 +586,7 @@ async fn resume_conversation( let auth_manager = codex_core::test_support::auth_manager_from_auth( codex_core::CodexAuth::from_api_key("dummy"), ); - manager - .resume_thread_from_rollout(config.clone(), path, auth_manager) + Box::pin(manager.resume_thread_from_rollout(config.clone(), path, auth_manager)) .await .expect("resume conversation") .thread @@ -621,8 +599,7 @@ async fn fork_thread( path: std::path::PathBuf, nth_user_message: usize, ) -> Arc { - manager - .fork_thread(nth_user_message, config.clone(), path, false) + Box::pin(manager.fork_thread(nth_user_message, config.clone(), path, false)) .await .expect("fork conversation") .thread From 4f6c4bb143a9c53ea762194248c95e85bf0ac9d3 Mon Sep 17 00:00:00 2001 From: Val Kharitonov Date: Tue, 3 Mar 2026 22:46:05 -0800 Subject: [PATCH 17/23] support 'flex' tier in app-server in addition to 'fast' (#13391) --- .../schema/json/ClientRequest.json | 3 +- .../schema/json/EventMsg.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/json/v2/ConfigReadResponse.json | 3 +- .../schema/json/v2/ThreadForkParams.json | 3 +- .../schema/json/v2/ThreadForkResponse.json | 3 +- .../schema/json/v2/ThreadResumeParams.json | 3 +- .../schema/json/v2/ThreadResumeResponse.json | 3 +- .../schema/json/v2/ThreadStartParams.json | 3 +- .../schema/json/v2/ThreadStartResponse.json | 3 +- .../schema/json/v2/TurnStartParams.json | 3 +- .../schema/typescript/ServiceTier.ts | 2 +- codex-rs/app-server/README.md | 3 ++ .../app-server/tests/suite/v2/thread_start.rs | 29 +++++++++++++++++++ codex-rs/core/config.schema.json | 5 ++-- codex-rs/core/src/client.rs | 3 +- codex-rs/core/src/client_common.rs | 26 +++++++++++++++++ codex-rs/core/src/config/mod.rs | 21 +++++++------- codex-rs/core/tests/suite/model_switching.rs | 20 +++++++++++++ codex-rs/protocol/src/config_types.rs | 1 + codex-rs/tui/src/chatwidget.rs | 7 +++-- 22 files changed, 123 insertions(+), 30 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 509d442678..1ae43c01b2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1728,7 +1728,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index cafd8bc5d0..fa1df138e6 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -5460,7 +5460,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 94e9f3a50c..a217af3106 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12134,7 +12134,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 0d1de74481..b2e21a80f4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10770,7 +10770,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 626db8daeb..dd0a86fe91 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -707,7 +707,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index ff0bf3f044..d4ef3effdb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -53,7 +53,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 41f4c9b7bb..896546347e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -744,7 +744,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 91850f60e5..a401c2141a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -759,7 +759,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 416bceb6e0..e712036051 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -744,7 +744,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 311b7b40cd..0cdc8d4598 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -78,7 +78,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 25db79cf24..57098610c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -744,7 +744,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 9e749d5b81..b1d5febacc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -305,7 +305,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts index bb2b5103e6..ce11286dbd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ServiceTier = "fast"; +export type ServiceTier = "fast" | "flex"; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 658b2e08d8..068d4ee18e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -180,6 +180,7 @@ Start a fresh thread when you need a new Codex conversation. "approvalPolicy": "never", "sandbox": "workspaceWrite", "personality": "friendly", + "serviceTier": "flex", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) // Experimental: requires opt-in "dynamicTools": [ @@ -208,6 +209,7 @@ Start a fresh thread when you need a new Codex conversation. ``` Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. +Valid `serviceTier` values are `"fast"` and `"flex"`. To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted. You can also pass the same configuration overrides supported by `thread/start`, such as `personality`: @@ -415,6 +417,7 @@ You can optionally specify config overrides on the new turn. If specified, these "networkAccess": true }, "model": "gpt-5.1-codex", + "serviceTier": "flex", "effort": "medium", "summary": "concise", "personality": "friendly", diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 57eddd195a..bc383a19d1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -180,6 +181,34 @@ model_reasoning_effort = "high" Ok(()) } +#[tokio::test] +async fn thread_start_accepts_flex_service_tier() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + service_tier: Some(Some(ServiceTier::Flex)), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; + + assert_eq!(service_tier, Some(ServiceTier::Flex)); + Ok(()) +} + #[tokio::test] async fn thread_start_accepts_metrics_service_name() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 71d624d2c0..3a108b854b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1336,7 +1336,8 @@ }, "ServiceTier": { "enum": [ - "fast" + "fast", + "flex" ], "type": "string" }, @@ -2146,7 +2147,7 @@ "$ref": "#/definitions/ServiceTier" } ], - "description": "Optional explicit service tier preference for new turns." + "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." }, "shell_environment_policy": { "allOf": [ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d81c10f396..7479623cbd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -546,7 +546,8 @@ impl ModelClientSession { include, service_tier: match service_tier { Some(ServiceTier::Fast) => Some("priority".to_string()), - _ => None, + Some(service_tier) => Some(service_tier.to_string()), + None => None, }, prompt_cache_key, text, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index cddf99d6f5..21c4c56660 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -234,6 +234,7 @@ mod tests { use codex_api::common::OpenAiVerbosity; use codex_api::common::TextControls; use codex_api::create_text_param_for_request; + use codex_protocol::config_types::ServiceTier; use codex_protocol::models::FunctionCallOutputPayload; use pretty_assertions::assert_eq; @@ -342,6 +343,31 @@ mod tests { assert!(v.get("text").is_none()); } + #[test] + fn serializes_flex_service_tier_when_set() { + let req = ResponsesApiRequest { + model: "gpt-5.1".to_string(), + instructions: "i".to_string(), + input: vec![], + tools: vec![], + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + service_tier: Some(ServiceTier::Flex.to_string()), + text: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + assert_eq!( + v.get("service_tier").and_then(|tier| tier.as_str()), + Some("flex") + ); + } + #[test] fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2d833d8b51..ca99cce54b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -189,7 +189,7 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier preference for new turns. + /// Effective service tier preference for new turns (`fast` or `flex`). pub service_tier: Option, /// Model used specifically for review sessions. @@ -1191,7 +1191,7 @@ pub struct ConfigToml { /// Optionally specify a personality for the model pub personality: Option, - /// Optional explicit service tier preference for new turns. + /// Optional explicit service tier preference for new turns (`fast` or `flex`). pub service_tier: Option, /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). @@ -1984,15 +1984,14 @@ impl Config { let forced_login_method = cfg.forced_login_method; let model = model.or(config_profile.model).or(cfg.model); - let service_tier = if features.enabled(Feature::FastMode) { - service_tier_override.unwrap_or_else(|| { - config_profile - .service_tier - .or(cfg.service_tier) - .filter(|tier| matches!(tier, ServiceTier::Fast)) - }) - } else { - None + let service_tier = service_tier_override + .unwrap_or_else(|| config_profile.service_tier.or(cfg.service_tier)); + let service_tier = match service_tier { + Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { + Some(ServiceTier::Fast) + } + Some(ServiceTier::Flex) => Some(ServiceTier::Flex), + _ => None, }; let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index df02d5e258..c58fb0fea8 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -22,6 +22,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_completed; @@ -247,6 +248,25 @@ async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await; + + let test = test_codex().build(&server).await?; + + test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex)) + .await?; + + let request = resp_mock.single_request(); + let body = request.body_json(); + assert_eq!(body["service_tier"].as_str(), Some("flex")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 984dcf39f1..b467d34857 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -118,6 +118,7 @@ pub enum WebSearchMode { #[strum(serialize_all = "lowercase")] pub enum ServiceTier { Fast, + Flex, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e4e33e795a..840505f36b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3681,7 +3681,7 @@ impl ChatWidget { self.open_model_popup(); } SlashCommand::Fast => { - let next_tier = if self.config.service_tier.is_some() { + let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { None } else { Some(ServiceTier::Fast) @@ -3976,7 +3976,8 @@ impl ChatWidget { "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), "off" => self.set_service_tier_selection(None), "status" => { - let status = if self.config.service_tier.is_some() { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) + { "on" } else { "off" @@ -4342,7 +4343,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); - let service_tier = self.fast_mode_enabled().then_some(self.config.service_tier); + let service_tier = self.config.service_tier.map(Some); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), From fa2306b303a8c7b1ef2ebb41117051bede08cb09 Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 3 Mar 2026 23:11:28 -0800 Subject: [PATCH 18/23] image-gen-core (#13290) Core tool-calling for image-gen, handles requesting and receiving logic for images using response API --- .../schema/json/ClientRequest.json | 34 ++ .../schema/json/EventMsg.json | 34 ++ .../codex_app_server_protocol.schemas.json | 34 ++ .../codex_app_server_protocol.v2.schemas.json | 34 ++ .../RawResponseItemCompletedNotification.json | 34 ++ .../schema/json/v2/ThreadResumeParams.json | 34 ++ .../schema/typescript/ResponseItem.ts | 2 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/client.rs | 1 - codex-rs/core/src/client_common.rs | 3 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/context_manager/history.rs | 9 +- .../core/src/context_manager/history_tests.rs | 91 ++++++ .../core/src/context_manager/normalize.rs | 28 +- codex-rs/core/src/features.rs | 14 + codex-rs/core/src/rollout/policy.rs | 2 + codex-rs/core/src/tools/spec.rs | 71 ++++- codex-rs/core/tests/common/responses.rs | 18 ++ codex-rs/core/tests/suite/model_switching.rs | 299 +++++++++++++++--- codex-rs/otel/src/traces/otel_manager.rs | 1 + codex-rs/protocol/src/models.rs | 60 ++++ codex-rs/protocol/src/openai_models.rs | 1 + 22 files changed, 766 insertions(+), 45 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 1ae43c01b2..e57ffa12c9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1416,6 +1416,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index fa1df138e6..526d852a2e 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -4983,6 +4983,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a217af3106..2ccd20466c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11761,6 +11761,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b2e21a80f4..1bbea24c8a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9324,6 +9324,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index cedfdb19c4..cb14542607 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -641,6 +641,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index a401c2141a..3dce8b5df1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -691,6 +691,40 @@ "title": "WebSearchCallResponseItem", "type": "object" }, + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "result", + "status", + "type" + ], + "title": "ImageGenerationCallResponseItem", + "type": "object" + }, { "properties": { "ghost_commit": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index dd7621f01d..dc42485935 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let conversation_id = self.client.state.conversation_id.to_string(); - ApiResponsesOptions { conversation_id: Some(conversation_id), session_source: Some(self.client.state.session_source.clone()), diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 21c4c56660..50facf53e4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -166,6 +166,8 @@ pub(crate) mod tools { Function(ResponsesApiTool), #[serde(rename = "local_shell")] LocalShell {}, + #[serde(rename = "image_generation")] + ImageGeneration {}, // TODO: Understand why we get an error on web_search although the API docs say it's supported. // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C // The `external_web_access` field determines whether the web search is over cached or live content. @@ -184,6 +186,7 @@ pub(crate) mod tools { match self { ToolSpec::Function(tool) => tool.name.as_str(), ToolSpec::LocalShell {} => "local_shell", + ToolSpec::ImageGeneration {} => "image_generation", ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(tool) => tool.name.as_str(), } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 6d8368bce4..40c520f659 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -206,6 +206,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => false, } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 40e0f31e41..1bafca4082 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -344,6 +344,9 @@ impl ContextManager { // all outputs must have a corresponding function/tool call normalize::remove_orphan_outputs(&mut self.items); + //rewrite image_gen_calls to messages to support stateless input + normalize::rewrite_image_generation_calls_for_stateless_input(&mut self.items); + // strip images when model does not support them normalize::strip_images_when_unsupported(input_modalities, &mut self.items); } @@ -374,6 +377,7 @@ impl ContextManager { | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::GhostSnapshot { .. } @@ -402,7 +406,8 @@ fn truncate_function_output_payload( } /// API messages include every non-system item (user/assistant messages, reasoning, -/// tool calls, tool outputs, shell calls, and web-search calls). +/// tool calls, tool outputs, shell calls, web-search calls, and image-generation +/// calls). fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", @@ -413,6 +418,7 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::LocalShellCall { .. } | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::GhostSnapshot { .. } => false, ResponseItem::Other => false, @@ -600,6 +606,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::Reasoning { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } => true, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 46aa462322..b678c2e6cb 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -395,6 +395,97 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { } } +#[test] +fn for_prompt_rewrites_image_generation_calls_when_images_are_supported() { + let history = create_history_with_items(vec![ + ResponseItem::ImageGenerationCall { + id: "ig_123".to_string(), + status: "generating".to_string(), + revised_prompt: Some("lobster".to_string()), + result: "Zm9v".to_string(), + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hi".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + + assert_eq!( + history.for_prompt(&default_input_modalities()), + vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,Zm9v".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hi".to_string(), + }], + end_turn: None, + phase: None, + } + ] + ); +} + +#[test] +fn for_prompt_rewrites_image_generation_calls_when_images_are_unsupported() { + let history = create_history_with_items(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "generate a lobster".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::ImageGenerationCall { + id: "ig_123".to_string(), + status: "completed".to_string(), + revised_prompt: Some("lobster".to_string()), + result: "Zm9v".to_string(), + }, + ]); + + assert_eq!( + history.for_prompt(&[InputModality::Text]), + vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "generate a lobster".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "image content omitted because you do not support image input" + .to_string(), + }], + end_turn: None, + phase: None, + }, + ] + ); +} + #[test] fn get_history_for_prompt_drops_ghost_commits() { let items = vec![ResponseItem::GhostSnapshot { diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 572ac51fc8..465edae41b 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -1,10 +1,9 @@ -use std::collections::HashSet; - use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; +use std::collections::HashSet; use crate::util::error_or_panic; use tracing::info; @@ -211,6 +210,31 @@ where } } +pub(crate) fn rewrite_image_generation_calls_for_stateless_input(items: &mut Vec) { + let original_items = std::mem::take(items); + *items = original_items + .into_iter() + .map(|item| match item { + ResponseItem::ImageGenerationCall { result, .. } => { + let image_url = if result.starts_with("data:") { + result + } else { + format!("data:image/png;base64,{result}") + }; + + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { image_url }], + end_turn: None, + phase: None, + } + } + _ => item, + }) + .collect(); +} + /// Strip image content from messages and tool outputs when the model does not support images. /// When `input_modalities` contains `InputModality::Image`, no stripping is performed. pub(crate) fn strip_images_when_unsupported( diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index ef6712fd00..dbc4e70685 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -131,6 +131,8 @@ pub enum Feature { Apps, /// Enable plugins. Plugins, + /// Allow the model to invoke the built-in image generation tool. + ImageGeneration, /// Route apps MCP calls through the configured gateway. AppsMcpGateway, /// Allow prompting and installing missing MCP dependencies. @@ -649,6 +651,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ImageGeneration, + key: "image_generation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::AppsMcpGateway, key: "apps_mcp_gateway", @@ -868,6 +876,12 @@ mod tests { assert_eq!(Feature::JsRepl.default_enabled(), false); } + #[test] + fn image_generation_is_under_development() { + assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ImageGeneration.default_enabled(), false); + } + #[test] fn collab_is_legacy_alias_for_multi_agent() { assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 4c76bc54f6..9af54610e2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -35,6 +35,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } => true, ResponseItem::Other => false, @@ -53,6 +54,7 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, ResponseItem::Reasoning { .. } + | ResponseItem::ImageGenerationCall { .. } | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } | ResponseItem::Other => false, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 0e53670750..3ff8bc50e1 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -23,6 +23,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -35,7 +36,6 @@ use std::collections::HashMap; const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); - #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ShellCommandBackendConfig { Classic, @@ -49,6 +49,7 @@ pub(crate) struct ToolsConfig { pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, + pub image_gen_tool: bool, pub agent_roles: BTreeMap, pub search_tool: bool, pub request_permission_enabled: bool, @@ -86,6 +87,8 @@ impl ToolsConfig { features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = features.enabled(Feature::Apps); let include_artifact_tools = features.enabled(Feature::Artifact); + let include_image_gen_tool = + features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info); let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite); let request_permission_enabled = features.enabled(Feature::RequestPermissions); let shell_command_backend = @@ -135,6 +138,7 @@ impl ToolsConfig { allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, + image_gen_tool: include_image_gen_tool, agent_roles: BTreeMap::new(), search_tool: include_search_tool, request_permission_enabled, @@ -160,6 +164,10 @@ impl ToolsConfig { } } +fn supports_image_generation(model_info: &ModelInfo) -> bool { + model_info.input_modalities.contains(&InputModality::Image) +} + /// Generic JSON‑Schema subset needed for our tool definitions #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "lowercase")] @@ -1917,6 +1925,10 @@ pub(crate) fn build_specs( Some(WebSearchMode::Disabled) | None => {} } + if config.image_gen_tool { + builder.push_spec(ToolSpec::ImageGeneration {}); + } + builder.push_spec_with_parallel_support(create_view_image_tool(), true); builder.register_handler("view_image", view_image_handler); @@ -1995,6 +2007,7 @@ mod tests { use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::tools::registry::ConfiguredToolSpec; + use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use pretty_assertions::assert_eq; @@ -2047,6 +2060,7 @@ mod tests { match tool { ToolSpec::Function(ResponsesApiTool { name, .. }) => name, ToolSpec::LocalShell {} => "local_shell", + ToolSpec::ImageGeneration {} => "image_generation", ToolSpec::WebSearch { .. } => "web_search", ToolSpec::Freeform(FreeformTool { name, .. }) => name, } @@ -2125,7 +2139,10 @@ mod tests { ToolSpec::Function(ResponsesApiTool { parameters, .. }) => { strip_descriptions_schema(parameters); } - ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch { .. } => {} + ToolSpec::Freeform(_) + | ToolSpec::LocalShell {} + | ToolSpec::ImageGeneration {} + | ToolSpec::WebSearch { .. } => {} } } @@ -2374,6 +2391,56 @@ mod tests { assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); } + #[test] + fn image_generation_tools_require_feature_and_supported_model() { + let config = test_config(); + let mut supported_model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config); + supported_model_info.slug = "custom/gpt-5.2-variant".to_string(); + let mut unsupported_model_info = supported_model_info.clone(); + unsupported_model_info.input_modalities = vec![InputModality::Text]; + let default_features = Features::with_defaults(); + let mut image_generation_features = default_features.clone(); + image_generation_features.enable(Feature::ImageGeneration); + + let default_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + features: &default_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build(); + assert!( + !default_tools + .iter() + .any(|tool| tool.spec.name() == "image_generation"), + "image_generation should be disabled by default" + ); + + let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build(); + assert_contains_tool_names(&supported_tools, &["image_generation"]); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &unsupported_model_info, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert!( + !tools + .iter() + .any(|tool| tool.spec.name() == "image_generation"), + "image_generation should be disabled for unsupported models" + ); + } + #[test] fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 4f2e89636e..d07b155f61 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -739,6 +739,24 @@ pub fn ev_web_search_call_done(id: &str, status: &str, query: &str) -> Value { }) } +pub fn ev_image_generation_call( + id: &str, + status: &str, + revised_prompt: &str, + result: &str, +) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "image_generation_call", + "id": id, + "status": status, + "revised_prompt": revised_prompt, + "result": result, + } + }) +} + pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index c58fb0fea8..a93d0f751b 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -20,6 +20,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_image_generation_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; @@ -33,6 +34,47 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use wiremock::MockServer; +fn test_model_info( + slug: &str, + display_name: &str, + description: &str, + input_modalities: Vec, +) -> ModelInfo { + ModelInfo { + slug: slug.to_string(), + display_name: display_name.to_string(), + description: Some(description.to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + supported_in_api: true, + input_modalities, + prefer_websockets: false, + used_fallback_model_metadata: false, + priority: 1, + upgrade: None, + base_instructions: "base instructions".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + availability_nux: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + supports_image_detail_original: false, + context_window: Some(272_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_change_appends_model_instructions_developer_message() -> Result<()> { skip_if_no_network!(Ok(())); @@ -274,44 +316,18 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let server = MockServer::start().await; let image_model_slug = "test-image-model"; let text_model_slug = "test-text-only-model"; - let image_model = ModelInfo { - slug: image_model_slug.to_string(), - display_name: "Test Image Model".to_string(), - description: Some("supports image input".to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: vec![ReasoningEffortPreset { - effort: ReasoningEffort::Medium, - description: ReasoningEffort::Medium.to_string(), - }], - shell_type: ConfigShellToolType::ShellCommand, - visibility: ModelVisibility::List, - supported_in_api: true, - input_modalities: default_input_modalities(), - prefer_websockets: false, - used_fallback_model_metadata: false, - priority: 1, - upgrade: None, - base_instructions: "base instructions".to_string(), - model_messages: None, - supports_reasoning_summaries: false, - default_reasoning_summary: ReasoningSummary::Auto, - support_verbosity: false, - default_verbosity: None, - availability_nux: None, - apply_patch_tool_type: None, - truncation_policy: TruncationPolicyConfig::bytes(10_000), - supports_parallel_tool_calls: false, - supports_image_detail_original: false, - context_window: Some(272_000), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - }; - let mut text_model = image_model.clone(); - text_model.slug = text_model_slug.to_string(); - text_model.display_name = "Test Text Model".to_string(); - text_model.description = Some("text only".to_string()); - text_model.input_modalities = vec![InputModality::Text]; + let image_model = test_model_info( + image_model_slug, + "Test Image Model", + "supports image input", + default_input_modalities(), + ); + let text_model = test_model_info( + text_model_slug, + "Test Text Model", + "text only", + vec![InputModality::Text], + ); mount_models_once( &server, ModelsResponse { @@ -421,6 +437,213 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + let image_model_slug = "test-image-model"; + let image_model = test_model_info( + image_model_slug, + "Test Image Model", + "supports image input", + default_input_modalities(), + ); + mount_models_once( + &server, + ModelsResponse { + models: vec![image_model], + }, + ) + .await; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_image_generation_call("ig_123", "completed", "lobster", "Zm9v"), + ev_completed_with_tokens("resp-1", 10), + ]), + sse_completed("resp-2"), + ], + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.model = Some(image_model_slug.to_string()); + }); + let test = builder.build(&server).await?; + let models_manager = test.thread_manager.get_models_manager(); + let _ = models_manager + .list_models(RefreshStrategy::OnlineIfUncached) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "generate a lobster".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: image_model_slug.to_string(), + effort: test.config.model_reasoning_effort, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "describe the generated image".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: image_model_slug.to_string(), + effort: test.config.model_reasoning_effort, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let second_request = requests.last().expect("expected second request"); + assert_eq!( + second_request.message_input_image_urls("user"), + vec!["data:image/png;base64,Zm9v".to_string()] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_change_from_generated_image_to_text_strips_prior_generated_image_content() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + let image_model_slug = "test-image-model"; + let text_model_slug = "test-text-only-model"; + let image_model = test_model_info( + image_model_slug, + "Test Image Model", + "supports image input", + default_input_modalities(), + ); + let text_model = test_model_info( + text_model_slug, + "Test Text Model", + "text only", + vec![InputModality::Text], + ); + mount_models_once( + &server, + ModelsResponse { + models: vec![image_model, text_model], + }, + ) + .await; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_image_generation_call("ig_123", "completed", "lobster", "Zm9v"), + ev_completed_with_tokens("resp-1", 10), + ]), + sse_completed("resp-2"), + ], + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.model = Some(image_model_slug.to_string()); + }); + let test = builder.build(&server).await?; + let models_manager = test.thread_manager.get_models_manager(); + let _ = models_manager + .list_models(RefreshStrategy::OnlineIfUncached) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "generate a lobster".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: image_model_slug.to_string(), + effort: test.config.model_reasoning_effort, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "describe the generated image".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: text_model_slug.to_string(), + effort: test.config.model_reasoning_effort, + service_tier: None, + summary: None, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let second_request = requests.last().expect("expected second request"); + assert!( + second_request.message_input_image_urls("user").is_empty(), + "second request should strip generated image content for text-only models" + ); + assert!( + second_request + .message_input_texts("user") + .iter() + .any(|text| text == "image content omitted because you do not support image input"), + "second request should include the image-omitted placeholder text" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index 1074b6edd1..aa11fce3b0 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -771,6 +771,7 @@ impl OtelManager { ResponseItem::CustomToolCall { .. } => "custom_tool_call".into(), ResponseItem::CustomToolCallOutput { .. } => "custom_tool_call_output".into(), ResponseItem::WebSearchCall { .. } => "web_search_call".into(), + ResponseItem::ImageGenerationCall { .. } => "image_generation_call".into(), ResponseItem::GhostSnapshot { .. } => "ghost_snapshot".into(), ResponseItem::Compaction { .. } => "compaction".into(), ResponseItem::Other => "other".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d12eef3b30..90d9945b06 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -313,6 +313,23 @@ pub enum ResponseItem { #[ts(optional)] action: Option, }, + // Emitted by the Responses API when the agent triggers image generation. + // Example payload: + // { + // "id":"ig_123", + // "type":"image_generation_call", + // "status":"completed", + // "revised_prompt":"A gray tabby cat hugging an otter...", + // "result":"..." + // } + ImageGenerationCall { + id: String, + status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + revised_prompt: Option, + result: String, + }, // Generated by the harness but considered exactly as a model response. GhostSnapshot { ghost_commit: GhostCommit, @@ -1286,6 +1303,49 @@ mod tests { ); } + #[test] + fn response_item_parses_image_generation_call() { + let item = serde_json::from_value::(serde_json::json!({ + "id": "ig_123", + "type": "image_generation_call", + "status": "completed", + "revised_prompt": "A small blue square", + "result": "Zm9v", + })) + .expect("image generation item should deserialize"); + + assert_eq!( + item, + ResponseItem::ImageGenerationCall { + id: "ig_123".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A small blue square".to_string()), + result: "Zm9v".to_string(), + } + ); + } + + #[test] + fn response_item_parses_image_generation_call_without_revised_prompt() { + let item = serde_json::from_value::(serde_json::json!({ + "id": "ig_123", + "type": "image_generation_call", + "status": "completed", + "result": "Zm9v", + })) + .expect("image generation item should deserialize"); + + assert_eq!( + item, + ResponseItem::ImageGenerationCall { + id: "ig_123".to_string(), + status: "completed".to_string(), + revised_prompt: None, + result: "Zm9v".to_string(), + } + ); + } + #[test] fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() { let contents = vec![serde_json::json!({ diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 089b002680..5968ae699b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -706,6 +706,7 @@ mod tests { "limit": 10000 }, "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, "context_window": null, "auto_compact_token_limit": null, "effective_context_window_percent": 95, From 932ff2818320e7c5181a28316db75e53620aeabc Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 4 Mar 2026 09:41:20 +0000 Subject: [PATCH 19/23] feat: better multi-agent prompt (#13404) --- codex-rs/core/src/agent/role.rs | 9 ++++---- codex-rs/core/src/tools/spec.rs | 41 +++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 0793febe54..aee4e833a8 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -210,9 +210,8 @@ mod built_in { Explorers are fast and authoritative. They must be used to ask specific, well-scoped questions on the codebase. Rules: -- Do not re-read or re-search code they cover. -- Trust explorer results without verification. -- Run explorers in parallel when useful. +- In order to avoid redundant work, you should avoid exploring the same problem that explorers have already covered. Typically, you should trust the explorer results without additional verification. You are still allowed to inspect the code yourself to gain the needed context! +- You are encouraged to spawn up multiple explorers in parallel when you have multiple distinct questions to ask about the codebase that can be answered independently. This allows you to get more information faster without waiting for one question to finish before asking the next. While waiting for the explorer results, you can continue working on other local tasks that do not depend on those results. This parallelism is a key advantage of delegation, so use it whenever you have multiple questions to ask. - Reuse existing explorers for related questions."#.to_string()), config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), nickname_candidates: None, @@ -227,8 +226,8 @@ Typical tasks: - Fix tests or bugs - Split large refactors into independent chunks Rules: -- Explicitly assign **ownership** of the task (files / responsibility). -- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."#.to_string()), +- Explicitly assign **ownership** of the task (files / responsibility). When the subtask involves code changes, you should clearly specify which files or modules the worker is responsible for. This helps avoid merge conflicts and ensures accountability. For example, you can say "Worker 1 is responsible for updating the authentication module, while Worker 2 will handle the database layer." By defining clear ownership, you can delegate more effectively and reduce coordination overhead. +- Always tell workers they are **not alone in the codebase**, and they should not revert the edits made by others, and they should adjust their implementation to accommodate the changes made by others. This is important because there may be multiple workers making changes in parallel, and they need to be aware of each other's work to avoid conflicts and ensure a cohesive final product."#.to_string()), config_file: None, nickname_candidates: None, } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 3ff8bc50e1..5d9c6644cc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -748,9 +748,37 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "spawn_agent".to_string(), - description: - "Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent." - .to_string(), + description: r#"Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool. + +### When to delegate vs. do the subtask yourself +- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it. +- Use the smaller subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step. +- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving. +- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step. + +### Designing delegated subtasks +- Subtasks must be concrete, well-defined, and self-contained. +- Delegated subtasks must materially advance the main task. +- Do not duplicate work between the main rollout and delegated subtasks. +- Avoid issuing multiple delegate calls on the same unresolved thread unless the new delegated task is genuinely different and necessary. +- Narrow the delegated ask to the concrete output you need next. +- For coding tasks, prefer delegating concrete code-change worker subtasks over read-only explorer analysis when the subagent can make a bounded patch in a clear write scope. +- When delegating coding work, instruct the submodel to edit files directly in its forked workspace and list the file paths it changed in the final answer. +- For code-edit subtasks, decompose work so each delegated task has a disjoint write set. + +### After you delegate +- Call wait very sparingly. Only call wait when you need the result immediately for the next critical-path step and you are blocked until it returns. +- Do not redo delegated subagent tasks yourself; focus on integrating results or tackling non-overlapping work. +- While the subagent is running in the background, do meaningful non-overlapping work immediately. +- Do not repeatedly wait by reflex. +- When a delegated coding task returns, quickly review the uploaded changes, then integrate or refine them. + +### Parallel delegation patterns +- Run multiple independent information-seeking subtasks in parallel when you have distinct questions that can be answered independently. +- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap. +- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration. +- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."# + .to_string(), strict: false, parameters: JsonSchema::Object { properties, @@ -916,9 +944,8 @@ fn create_send_input_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "send_input".to_string(), - description: - "Send a message to an existing agent. Use interrupt=true to redirect work immediately." - .to_string(), + description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task." + .to_string(), strict: false, parameters: JsonSchema::Object { properties, @@ -974,7 +1001,7 @@ fn create_wait_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait".to_string(), - description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches his final status, a notification message will be received containing the same completed status." + description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, parameters: JsonSchema::Object { From a4ad101125cc2166d6675aea8309193f07c6f04c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 4 Mar 2026 09:41:29 +0000 Subject: [PATCH 20/23] feat: ordinal nick name (#13412) --- codex-rs/core/src/agent/guards.rs | 85 ++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 4213cd0ed0..056d2b7f6a 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -30,6 +30,26 @@ struct ActiveAgents { used_agent_nicknames: HashSet, nickname_reset_count: usize, } + +fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { + match nickname_reset_count { + 0 => name.to_string(), + reset_count => { + let value = reset_count + 1; + let suffix = match value % 100 { + 11..=13 => "th", + _ => match value % 10 { + 1 => "st", // codespell:ignore + 2 => "nd", // codespell:ignore + 3 => "rd", // codespell:ignore + _ => "th", // codespell:ignore + }, + }; + format!("{name} the {value}{suffix}") + } + } +} + fn session_depth(session_source: &SessionSource) -> i32 { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, @@ -107,20 +127,23 @@ impl Guards { if names.is_empty() { return None; } - let available_names: Vec<&str> = names + let available_names: Vec = names .iter() - .copied() - .filter(|name| !active_agents.used_agent_nicknames.contains(*name)) + .map(|name| format_agent_nickname(name, active_agents.nickname_reset_count)) + .filter(|name| !active_agents.used_agent_nicknames.contains(name)) .collect(); if let Some(name) = available_names.choose(&mut rand::rng()) { - (*name).to_string() + name.clone() } else { active_agents.used_agent_nicknames.clear(); active_agents.nickname_reset_count += 1; if let Some(metrics) = codex_otel::metrics::global() { let _ = metrics.counter("codex.multi_agent.nickname_pool_reset", 1, &[]); } - names.choose(&mut rand::rng())?.to_string() + format_agent_nickname( + names.choose(&mut rand::rng())?, + active_agents.nickname_reset_count, + ) } }; active_agents @@ -202,6 +225,16 @@ impl Drop for SpawnReservation { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::collections::HashSet; + + #[test] + fn format_agent_nickname_adds_ordinals_after_reset() { + assert_eq!(format_agent_nickname("Plato", 0), "Plato"); + assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd"); + assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd"); + assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th"); + assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st"); + } #[test] fn session_depth_defaults_to_zero_for_root_sources() { @@ -352,7 +385,7 @@ mod tests { let second_name = second .reserve_agent_nickname(&["alpha"]) .expect("name should be reused after pool reset"); - assert_eq!(second_name, "alpha"); + assert_eq!(second_name, "alpha the 2nd"); let active_agents = guards .active_agents .lock() @@ -389,11 +422,49 @@ mod tests { let third_name = third .reserve_agent_nickname(&["alpha", "beta"]) .expect("pool reset should permit a duplicate"); - assert!(third_name == "alpha" || third_name == "beta"); + let expected_names = + HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); + assert!(expected_names.contains(&third_name)); let active_agents = guards .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); assert_eq!(active_agents.nickname_reset_count, 1); } + + #[test] + fn repeated_resets_advance_the_ordinal_suffix() { + let guards = Arc::new(Guards::default()); + + let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname(&["Plato"]) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(first_id); + assert_eq!(first_name, "Plato"); + guards.release_spawned_thread(first_id); + + let mut second = guards + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname(&["Plato"]) + .expect("reserve second agent name"); + let second_id = ThreadId::new(); + second.commit(second_id); + assert_eq!(second_name, "Plato the 2nd"); + guards.release_spawned_thread(second_id); + + let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname(&["Plato"]) + .expect("reserve third agent name"); + assert_eq!(third_name, "Plato the 3rd"); + let active_agents = guards + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 2); + } } From 49634b7f9c0702598bbd5a233ddcb8bad5302e79 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 4 Mar 2026 10:17:25 +0000 Subject: [PATCH 21/23] add metric for per-turn token usage (#13454) --- codex-rs/core/src/codex.rs | 7 ++--- codex-rs/core/src/state/turn.rs | 2 ++ codex-rs/core/src/tasks/mod.rs | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9b9e0214a8..0ac2b14f4b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3238,12 +3238,9 @@ impl Session { turn_context: &TurnContext, token_usage: Option<&TokenUsage>, ) { - { + if let Some(token_usage) = token_usage { let mut state = self.state.lock().await; - if let Some(token_usage) = token_usage { - state - .update_token_info_from_usage(token_usage, turn_context.model_context_window()); - } + state.update_token_info_from_usage(token_usage, turn_context.model_context_window()); } self.send_token_count_event(turn_context).await; } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ccc50d066b..9366880aca 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -15,6 +15,7 @@ use tokio::sync::oneshot; use crate::codex::TurnContext; use crate::protocol::ReviewDecision; +use crate::protocol::TokenUsage; use crate::tasks::SessionTask; /// Metadata about the currently running turn. @@ -73,6 +74,7 @@ pub(crate) struct TurnState { pending_user_input: HashMap>, pending_dynamic_tools: HashMap>, pending_input: Vec, + pub(crate) token_usage_at_turn_start: TokenUsage, } impl TurnState { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 5133dc1e27..a814d36dcc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -199,11 +199,13 @@ impl Session { let mut active = self.active_turn.lock().await; let mut pending_input = Vec::::new(); let mut should_clear_active_turn = false; + let mut token_usage_at_turn_start = None; if let Some(at) = active.as_mut() && at.remove_task(&turn_context.sub_id) { let mut ts = at.turn_state.lock().await; pending_input = ts.take_pending_input(); + token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone()); should_clear_active_turn = true; } if should_clear_active_turn { @@ -235,6 +237,52 @@ impl Session { } } } + // Emit token usage metrics. + if let Some(token_usage_at_turn_start) = token_usage_at_turn_start { + let total_token_usage = self.total_token_usage().await.unwrap_or_default(); + let turn_token_usage = crate::protocol::TokenUsage { + input_tokens: (total_token_usage.input_tokens + - token_usage_at_turn_start.input_tokens) + .max(0), + cached_input_tokens: (total_token_usage.cached_input_tokens + - token_usage_at_turn_start.cached_input_tokens) + .max(0), + output_tokens: (total_token_usage.output_tokens + - token_usage_at_turn_start.output_tokens) + .max(0), + reasoning_output_tokens: (total_token_usage.reasoning_output_tokens + - token_usage_at_turn_start.reasoning_output_tokens) + .max(0), + total_tokens: (total_token_usage.total_tokens + - token_usage_at_turn_start.total_tokens) + .max(0), + }; + self.services.otel_manager.histogram( + "codex.turn.token_usage", + turn_token_usage.total_tokens, + &[("token_type", "total")], + ); + self.services.otel_manager.histogram( + "codex.turn.token_usage", + turn_token_usage.input_tokens, + &[("token_type", "input")], + ); + self.services.otel_manager.histogram( + "codex.turn.token_usage", + turn_token_usage.cached_input(), + &[("token_type", "cached_input")], + ); + self.services.otel_manager.histogram( + "codex.turn.token_usage", + turn_token_usage.output_tokens, + &[("token_type", "output")], + ); + self.services.otel_manager.histogram( + "codex.turn.token_usage", + turn_token_usage.reasoning_output_tokens, + &[("token_type", "reasoning_output")], + ); + } let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, @@ -243,8 +291,10 @@ impl Session { } async fn register_new_active_task(&self, task: RunningTask) { + let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default(); let mut active = self.active_turn.lock().await; let mut turn = ActiveTurn::default(); + turn.turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start; turn.add_task(task); *active = Some(turn); } From e4a202ea52439fd14f2eddd465c0e9824f8d1fb6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 4 Mar 2026 10:17:29 +0000 Subject: [PATCH 22/23] fix: pending messages in `/agent` (#13240) --- codex-rs/tui/src/app.rs | 799 +++++++++++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 9 + codex-rs/tui/src/chatwidget.rs | 122 +++- codex-rs/tui/src/chatwidget/tests.rs | 25 + codex-rs/utils/sleep-inhibitor/src/lib.rs | 12 + 5 files changed, 960 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd20187508..da196ef2c5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -12,6 +12,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; +use crate::chatwidget::ThreadInputState; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; @@ -254,6 +255,7 @@ struct SessionSummary { struct ThreadEventSnapshot { session_configured: Option, events: Vec, + input_state: Option, } #[derive(Debug)] @@ -262,6 +264,7 @@ struct ThreadEventStore { buffer: VecDeque, user_message_ids: HashSet, pending_interactive_replay: PendingInteractiveReplayState, + input_state: Option, capacity: usize, active: bool, } @@ -273,6 +276,7 @@ impl ThreadEventStore { buffer: VecDeque::new(), user_message_ids: HashSet::new(), pending_interactive_replay: PendingInteractiveReplayState::default(), + input_state: None, capacity, active: false, } @@ -342,6 +346,7 @@ impl ThreadEventStore { }) .cloned() .collect(), + input_state: self.input_state.clone(), } } @@ -917,13 +922,15 @@ impl App { let Some(active_id) = self.active_thread_id else { return; }; - let Some(receiver) = self.active_thread_rx.take() else { - return; - }; + let input_state = self.chat_widget.capture_thread_input_state(); if let Some(channel) = self.thread_event_channels.get_mut(&active_id) { + let receiver = self.active_thread_rx.take(); let mut store = channel.store.lock().await; store.active = false; - channel.receiver = Some(receiver); + store.input_state = input_state; + if let Some(receiver) = receiver { + channel.receiver = Some(receiver); + } } } @@ -1335,7 +1342,7 @@ impl App { self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); self.reset_for_thread_switch(tui)?; - self.replay_thread_snapshot(snapshot); + self.replay_thread_snapshot(snapshot, !is_replay_only); if is_replay_only { self.chat_widget.add_info_message( format!("Agent thread {thread_id} is closed. Replaying saved transcript."), @@ -1473,13 +1480,24 @@ impl App { (active_thread_id != primary_thread_id).then_some((active_thread_id, primary_thread_id)) } - fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) { + fn replay_thread_snapshot( + &mut self, + snapshot: ThreadEventSnapshot, + resume_restored_queue: bool, + ) { if let Some(event) = snapshot.session_configured { self.handle_codex_event_replay(event); } + self.chat_widget.set_queue_autosend_suppressed(true); + self.chat_widget + .restore_thread_input_state(snapshot.input_state); for event in snapshot.events { self.handle_codex_event_replay(event); } + self.chat_widget.set_queue_autosend_suppressed(false); + if resume_restored_queue { + self.chat_widget.maybe_send_next_queued_input(); + } self.refresh_status_line(); } @@ -3667,7 +3685,12 @@ mod tests { use codex_core::config::types::ModelAvailabilityNuxConfig; use codex_otel::OtelManager; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::CollaborationModeMask; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelAvailabilityNux; + use codex_protocol::protocol::AgentMessageDeltaEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -3675,6 +3698,10 @@ mod tests { use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadRolledBackEvent; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -3977,6 +4004,755 @@ mod tests { Ok(()) } + #[tokio::test] + async fn replay_thread_snapshot_restores_draft_and_queued_input() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session_configured( + THREAD_EVENT_CHANNEL_CAPACITY, + Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }, + ), + ); + app.activate_thread_channel(thread_id).await; + + app.chat_widget + .apply_external_edit("draft prompt".to_string()); + app.chat_widget.submit_user_message_with_mode( + "queued follow-up".to_string(), + CollaborationModeMask { + name: "Default".to_string(), + mode: None, + model: None, + reasoning_effort: None, + developer_instructions: None, + }, + ); + let expected_input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected thread input state"); + + app.store_active_thread_receiver().await; + + let snapshot = { + let channel = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel should exist"); + let store = channel.store.lock().await; + assert_eq!(store.input_state, Some(expected_input_state)); + store.snapshot() + }; + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + + app.replay_thread_snapshot(snapshot, true); + + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up submission, got {other:?}"), + } + } + + #[tokio::test] + async fn replayed_turn_complete_submits_restored_queued_follow_up() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "agent-delta".to_string(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "streaming".to_string(), + }), + }); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }], + input_state: Some(input_state), + }, + true, + ); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up submission, got {other:?}"), + } + } + + #[tokio::test] + async fn replay_only_thread_keeps_restored_queue_visible() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "agent-delta".to_string(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "streaming".to_string(), + }), + }); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![Event { + id: "turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }], + input_state: Some(input_state), + }, + false, + ); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + assert!( + new_op_rx.try_recv().is_err(), + "replay-only threads should not auto-submit restored queue" + ); + } + + #[tokio::test] + async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "agent-delta".to_string(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "streaming".to_string(), + }), + }); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![], + input_state: Some(input_state), + }, + true, + ); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + assert!( + new_op_rx.try_recv().is_err(), + "restored queue should stay queued when replay did not prove the turn finished" + ); + } + + #[tokio::test] + async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "agent-delta".to_string(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "streaming".to_string(), + }), + }); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![ + Event { + id: "older-turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-0".to_string(), + last_agent_message: None, + }), + }, + Event { + id: "latest-turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }, + ], + input_state: Some(input_state), + }, + true, + ); + + assert!( + new_op_rx.try_recv().is_err(), + "queued follow-up should stay queued until the latest turn completes" + ); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + + app.chat_widget.handle_codex_event(Event { + id: "latest-turn-complete".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + }), + }); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued follow-up submission, got {other:?}"), + } + } + + #[tokio::test] + async fn replay_thread_snapshot_restores_pending_pastes_for_submit() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session_configured( + THREAD_EVENT_CHANNEL_CAPACITY, + Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }, + ), + ); + app.activate_thread_channel(thread_id).await; + + let large = "x".repeat(1005); + app.chat_widget.handle_paste(large.clone()); + let expected_input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected thread input state"); + + app.store_active_thread_receiver().await; + + let snapshot = { + let channel = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel should exist"); + let store = channel.store.lock().await; + assert_eq!(store.input_state, Some(expected_input_state)); + store.snapshot() + }; + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.replay_thread_snapshot(snapshot, true); + + assert_eq!(app.chat_widget.composer_text_with_pending(), large); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: large, + text_elements: Vec::new(), + }] + ), + other => panic!("expected restored paste submission, got {other:?}"), + } + } + + #[tokio::test] + async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-restored".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::High)), + developer_instructions: None, + }); + app.chat_widget + .apply_external_edit("draft prompt".to_string()); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected draft input state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Default".to_string(), + mode: Some(ModeKind::Default), + model: Some("gpt-replacement".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::Low)), + developer_instructions: None, + }); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![], + input_state: Some(input_state), + }, + true, + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match next_user_turn_op(&mut new_op_rx) { + Op::UserTurn { + items, + model, + effort, + collaboration_mode, + .. + } => { + assert_eq!( + items, + vec![UserInput::Text { + text: "draft prompt".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!(model, "gpt-restored".to_string()); + assert_eq!(effort, Some(ReasoningEffortConfig::High)); + assert_eq!( + collaboration_mode, + Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-restored".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }) + ); + } + other => panic!("expected restored draft submission, got {other:?}"), + } + } + + #[tokio::test] + async fn replay_thread_snapshot_restores_collaboration_mode_without_input() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-restored".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::High)), + developer_instructions: None, + }); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected collaboration-only input state"); + + let (chat_widget, _app_event_tx, _rx, _new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Default".to_string(), + mode: Some(ModeKind::Default), + model: Some("gpt-replacement".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::Low)), + developer_instructions: None, + }); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![], + input_state: Some(input_state), + }, + true, + ); + + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!(app.chat_widget.current_model(), "gpt-restored"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + } + + #[tokio::test] + async fn replayed_interrupted_turn_restores_queued_input_to_composer() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session_configured = Event { + id: "session-configured".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }; + app.chat_widget + .handle_codex_event(session_configured.clone()); + app.chat_widget.handle_codex_event(Event { + id: "turn-started".to_string(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }); + app.chat_widget.handle_codex_event(Event { + id: "agent-delta".to_string(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "streaming".to_string(), + }), + }); + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected queued follow-up state"); + + let (chat_widget, _app_event_tx, _rx, mut new_op_rx) = + make_chatwidget_manual_with_sender().await; + app.chat_widget = chat_widget; + app.chat_widget.handle_codex_event(session_configured); + while new_op_rx.try_recv().is_ok() {} + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session_configured: None, + events: vec![Event { + id: "turn-aborted".to_string(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::ReviewEnded, + }), + }], + input_state: Some(input_state), + }, + true, + ); + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "queued follow-up" + ); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + assert!( + new_op_rx.try_recv().is_err(), + "replayed interrupted turns should restore queued input for editing, not submit it" + ); + } + #[tokio::test] async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { let mut app = make_test_app().await; @@ -4564,6 +5340,17 @@ mod tests { ) } + fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { + let mut seen = Vec::new(); + while let Ok(op) = op_rx.try_recv() { + if matches!(op, Op::UserTurn { .. }) { + return op; + } + seen.push(format!("{op:?}")); + } + panic!("expected UserTurn op, saw: {seen:?}"); + } + fn test_otel_manager(config: &Config, model: &str) -> OtelManager { let model_info = codex_core::test_support::construct_model_info_offline(model, config); OtelManager::new( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 74862df999..1e6e6fce0e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -579,6 +579,10 @@ impl BottomPane { self.composer.current_text_with_pending() } + pub(crate) fn composer_pending_pastes(&self) -> Vec<(String, String)> { + self.composer.pending_pastes() + } + pub(crate) fn apply_external_edit(&mut self, text: String) { self.composer.apply_external_edit(text); self.request_redraw(); @@ -604,6 +608,11 @@ impl BottomPane { urls } + pub(crate) fn set_composer_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { + self.composer.set_pending_pastes(pending_pastes); + self.request_redraw(); + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 840505f36b..fb9404a089 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -607,6 +607,7 @@ pub(crate) struct ChatWidget { retry_status_header: Option, // Set when commentary output completes; once stream queues go idle we restore the status row. pending_status_indicator_restore: bool, + suppress_queue_autosend: bool, thread_id: Option, thread_name: Option, forked_from: Option, @@ -721,6 +722,7 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } +#[derive(Debug, Clone, PartialEq)] pub(crate) struct UserMessage { text: String, local_images: Vec, @@ -734,6 +736,36 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Debug, Clone, PartialEq, Default)] +struct ThreadComposerState { + text: String, + local_images: Vec, + remote_image_urls: Vec, + text_elements: Vec, + mention_bindings: Vec, + pending_pastes: Vec<(String, String)>, +} + +impl ThreadComposerState { + fn has_content(&self) -> bool { + !self.text.is_empty() + || !self.local_images.is_empty() + || !self.remote_image_urls.is_empty() + || !self.text_elements.is_empty() + || !self.mention_bindings.is_empty() + || !self.pending_pastes.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ThreadInputState { + composer: Option, + queued_user_messages: VecDeque, + current_collaboration_mode: CollaborationMode, + active_collaboration_mask: Option, + agent_turn_running: bool, +} + impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -2021,6 +2053,80 @@ impl ChatWidget { ); } + pub(crate) fn capture_thread_input_state(&self) -> Option { + let composer = ThreadComposerState { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + mention_bindings: self.bottom_pane.composer_mention_bindings(), + pending_pastes: self.bottom_pane.composer_pending_pastes(), + }; + Some(ThreadInputState { + composer: composer.has_content().then_some(composer), + queued_user_messages: self.queued_user_messages.clone(), + current_collaboration_mode: self.current_collaboration_mode.clone(), + active_collaboration_mask: self.active_collaboration_mask.clone(), + agent_turn_running: self.agent_turn_running, + }) + } + + pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + if let Some(input_state) = input_state { + self.current_collaboration_mode = input_state.current_collaboration_mode; + self.active_collaboration_mask = input_state.active_collaboration_mask; + self.agent_turn_running = input_state.agent_turn_running; + self.update_collaboration_mode_indicator(); + self.refresh_model_display(); + if let Some(composer) = input_state.composer { + let local_image_paths = composer + .local_images + .into_iter() + .map(|img| img.path) + .collect(); + self.set_remote_image_urls(composer.remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + composer.text, + composer.text_elements, + local_image_paths, + composer.mention_bindings, + ); + self.bottom_pane + .set_composer_pending_pastes(composer.pending_pastes); + } else { + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + } + self.queued_user_messages = input_state.queued_user_messages; + } else { + self.agent_turn_running = false; + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + self.queued_user_messages.clear(); + } + self.turn_sleep_inhibitor + .set_turn_running(self.agent_turn_running); + self.update_task_running_state(); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + + pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { + self.suppress_queue_autosend = suppressed; + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.saw_plan_update_this_turn = true; self.add_to_history(history_cell::new_plan_update(update)); @@ -2977,6 +3083,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, pending_status_indicator_restore: false, + suppress_queue_autosend: false, thread_id: None, thread_name: None, forked_from: None, @@ -3158,6 +3265,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, pending_status_indicator_restore: false, + suppress_queue_autosend: false, thread_id: None, thread_name: None, forked_from: None, @@ -3328,6 +3436,7 @@ impl ChatWidget { current_status_header: String::from("Working"), retry_status_header: None, pending_status_indicator_restore: false, + suppress_queue_autosend: false, thread_id: None, thread_name: None, forked_from: None, @@ -4881,7 +4990,10 @@ impl ChatWidget { } // If idle and there are queued inputs, submit exactly one to start the next turn. - fn maybe_send_next_queued_input(&mut self) { + pub(crate) fn maybe_send_next_queued_input(&mut self) { + if self.suppress_queue_autosend { + return; + } if self.bottom_pane.is_task_running() { return; } @@ -7827,6 +7939,14 @@ impl ChatWidget { self.bottom_pane.remote_image_urls() } + #[cfg(test)] + pub(crate) fn queued_user_message_texts(&self) -> Vec { + self.queued_user_messages + .iter() + .map(|message| message.text.clone()) + .collect() + } + #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.bottom_pane.pending_thread_approvals() diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2861be570d..b9167d211c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1805,6 +1805,7 @@ async fn make_chatwidget_manual( current_status_header: String::from("Working"), retry_status_header: None, pending_status_indicator_restore: false, + suppress_queue_autosend: false, thread_id: None, thread_name: None, forked_from: None, @@ -3367,6 +3368,30 @@ async fn empty_enter_during_task_does_not_queue() { assert!(chat.queued_user_messages.is_empty()); } +#[tokio::test] +async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::PreventIdleSleep, true); + + chat.restore_thread_input_state(Some(ThreadInputState { + composer: None, + queued_user_messages: VecDeque::new(), + current_collaboration_mode: chat.current_collaboration_mode.clone(), + active_collaboration_mask: chat.active_collaboration_mask.clone(), + agent_turn_running: true, + })); + + assert!(chat.agent_turn_running); + assert!(chat.turn_sleep_inhibitor.is_turn_running()); + assert!(chat.bottom_pane.is_task_running()); + + chat.restore_thread_input_state(None); + + assert!(!chat.agent_turn_running); + assert!(!chat.turn_sleep_inhibitor.is_turn_running()); + assert!(!chat.bottom_pane.is_task_running()); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs index 8c9c5a9ccd..0b3d2c9a30 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -29,6 +29,7 @@ use windows_inhibitor as imp; #[derive(Debug)] pub struct SleepInhibitor { enabled: bool, + turn_running: bool, platform: imp::SleepInhibitor, } @@ -36,12 +37,14 @@ impl SleepInhibitor { pub fn new(enabled: bool) -> Self { Self { enabled, + turn_running: false, platform: imp::SleepInhibitor::new(), } } /// Update the active turn state; turns sleep prevention on/off as needed. pub fn set_turn_running(&mut self, turn_running: bool) { + self.turn_running = turn_running; if !self.enabled { self.release(); return; @@ -61,6 +64,11 @@ impl SleepInhibitor { fn release(&mut self) { self.platform.release(); } + + /// Return the latest turn-running state requested by the caller. + pub fn is_turn_running(&self) -> bool { + self.turn_running + } } #[cfg(test)] @@ -71,14 +79,18 @@ mod tests { fn sleep_inhibitor_toggles_without_panicking() { let mut inhibitor = SleepInhibitor::new(true); inhibitor.set_turn_running(true); + assert!(inhibitor.is_turn_running()); inhibitor.set_turn_running(false); + assert!(!inhibitor.is_turn_running()); } #[test] fn sleep_inhibitor_disabled_does_not_panic() { let mut inhibitor = SleepInhibitor::new(false); inhibitor.set_turn_running(true); + assert!(inhibitor.is_turn_running()); inhibitor.set_turn_running(false); + assert!(!inhibitor.is_turn_running()); } #[test] From e6b2e3a9f7ab3e87626db8de34632d3d61c083b8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 4 Mar 2026 11:00:48 +0000 Subject: [PATCH 23/23] fix: bad merge (#13461) --- codex-rs/tui/src/app.rs | 9 +++++++++ codex-rs/tui/src/chatwidget.rs | 14 ++++++++++++-- codex-rs/tui/src/chatwidget/tests.rs | 1 + 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index da196ef2c5..fe8aca54fd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4020,6 +4020,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4096,6 +4097,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4176,6 +4178,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4255,6 +4258,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4328,6 +4332,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4440,6 +4445,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4508,6 +4514,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4610,6 +4617,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), @@ -4685,6 +4693,7 @@ mod tests { thread_name: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), + service_tier: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), cwd: PathBuf::from("/tmp/project"), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fb9404a089..8c4f4ef57b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -760,6 +760,7 @@ impl ThreadComposerState { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, + pending_steers: VecDeque, queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, @@ -2064,6 +2065,11 @@ impl ChatWidget { }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), + pending_steers: self + .pending_steers + .iter() + .map(|pending| pending.user_message.clone()) + .collect(), queued_user_messages: self.queued_user_messages.clone(), current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), @@ -2103,9 +2109,13 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); } - self.queued_user_messages = input_state.queued_user_messages; + self.pending_steers.clear(); + self.queued_user_messages = input_state.pending_steers; + self.queued_user_messages + .extend(input_state.queued_user_messages); } else { self.agent_turn_running = false; + self.pending_steers.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -2119,7 +2129,7 @@ impl ChatWidget { self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); self.update_task_running_state(); - self.refresh_queued_user_messages(); + self.refresh_pending_input_preview(); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b9167d211c..2ece156517 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3375,6 +3375,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, + pending_steers: VecDeque::new(), queued_user_messages: VecDeque::new(), current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(),