From f4dc9cb08ddbc76c857d08d616ee010b9e071839 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:35:36 +0000 Subject: [PATCH 01/12] tui: stable agent-state terminal title Add a terminal title item that writes "codex: ready|working|awaiting approval" only when the state changes, push the user's title before the first write, and pop it on exit. Idle with animations off schedules no refresh. --- FORK.md | 2 +- codex-rs/tui/src/app.rs | 4 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 4 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + ...erminal_title_setup_popup_rate_limits.snap | 3 +- ...rminal_title_setup_popup_thread_usage.snap | 3 +- .../tui/src/chatwidget/status_surfaces.rs | 58 +++++++++++++ .../src/chatwidget/tests/terminal_title.rs | 69 +++++++++++++++ codex-rs/tui/src/terminal_title.rs | 85 ++++++++++++++++++- 10 files changed, 225 insertions(+), 7 deletions(-) diff --git a/FORK.md b/FORK.md index 09bfc863c187..6dfbc071e027 100644 --- a/FORK.md +++ b/FORK.md @@ -49,7 +49,7 @@ Record the new base tag and any dropped or reworked patch in the table below. | Commit | What | Upstream status | |---|---|---| -| — | none yet | — | +| — | `agent-state` terminal title item (`codex: ready\|working\|awaiting approval`), written only on change, with the user's title pushed (`CSI 22;0t`) at the first title write and popped (`CSI 23;0t`) on exit. Idle with `animations = false` schedules no refresh. SHA filled once the branch commit lands. | fork-only; Klaudia contract from vogt `internal/tui/idle.go` | ## External adapters diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5dfc3ad7b2cd..bb1da0d680d5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1231,8 +1231,8 @@ impl App { impl Drop for App { fn drop(&mut self) { - if let Err(err) = self.chat_widget.clear_managed_terminal_title() { - tracing::debug!(error = %err, "failed to clear terminal title on app drop"); + if let Err(err) = self.chat_widget.restore_terminal_title() { + tracing::debug!(error = %err, "failed to restore terminal title on app drop"); } } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index 7389236c07c6..b2c29894f88c 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -14,6 +14,7 @@ pub(crate) enum StatusSurfacePreviewItem { CurrentDir, Hostname, Status, + AgentState, ThreadName, ThreadTitle, GitBranch, @@ -52,6 +53,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::CurrentDir => "~/my-project/subdir", StatusSurfacePreviewItem::Hostname => "my-host", StatusSurfacePreviewItem::Status => "Working", + StatusSurfacePreviewItem::AgentState => "codex: working", StatusSurfacePreviewItem::ThreadName => "thread name", StatusSurfacePreviewItem::ThreadTitle => "thread title", StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature", @@ -90,6 +92,7 @@ impl StatusSurfacePreviewItem { Self::CurrentDir, Self::Hostname, Self::Status, + Self::AgentState, Self::ThreadName, Self::ThreadTitle, Self::GitBranch, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7dbb54bb8576..70c6e74ef650 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -73,6 +73,8 @@ use crate::status::rate_limit_snapshot_display_for_limit; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; +use crate::terminal_title::pop_terminal_title; +use crate::terminal_title::push_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::token_usage::TokenUsage; @@ -725,6 +727,8 @@ pub(crate) struct ChatWidget { terminal_title_invalid_items_warned: Arc, // Last terminal title emitted, to avoid writing duplicate OSC updates. pub(crate) last_terminal_title: Option, + /// True after the user's title has been pushed (`CSI 22 ; 0 t`) and not yet popped. + pub(crate) terminal_title_pushed: bool, // Last visible "action required" state observed by the terminal-title renderer. last_terminal_title_requires_action: bool, // Original terminal-title config captured when the setup UI opens. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index dc5adf7b7398..29cd706c6d48 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -261,6 +261,7 @@ impl ChatWidget { status_line_invalid_items_warned, terminal_title_invalid_items_warned, last_terminal_title: None, + terminal_title_pushed: false, last_terminal_title_requires_action: false, terminal_title_setup_original_items: None, terminal_title_animation_origin: Instant::now(), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap index 3eca27be4d10..1867bb91bb4c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_rate_limits.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests/status_surface_previews.rs +assertion_line: 529 expression: terminal_title_popup_snapshot(&mut chat) --- Configure Terminal Title @@ -14,7 +15,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] activity Spinner while working, action-required message while blocked [ ] run-state Compact session run-state text (Ready, Working, Thinking) - [ ] thread-name Current thread name (omitted when unnamed) + [ ] agent-state Stable agent state for a session manager (codex: ready, working, or awaiting a… ↓ monthly 65% left | weekly 50% left space toggle · ←/→ reorder · enter save · esc cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap index 3b417f4cfac4..2fbce369e197 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__terminal_title_setup_popup_thread_usage.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests/status_surface_previews.rs +assertion_line: 543 expression: terminal_title_popup_snapshot(&mut chat) --- Configure Terminal Title @@ -14,7 +15,7 @@ expression: terminal_title_popup_snapshot(&mut chat) [ ] current-dir Current working directory [ ] activity Spinner while working, action-required message while blocked [ ] run-state Compact session run-state text (Ready, Working, Thinking) - [ ] thread-name Current thread name (omitted when unnamed) + [ ] agent-state Stable agent state for a session manager (codex: ready, working, or aw… ↓ 5.2 credits | ~$1.82 space toggle · ←/→ reorder · enter save · esc cancel diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 46727cfc3210..67d8322d7470 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -242,6 +242,35 @@ impl ChatWidget { Ok(()) } + /// Saves the user's window title once, before the first OSC title write. + /// + /// Reading the previous title is not portable. `CSI 22 ; 0 t` pushes it on + /// the terminal's own stack so [`restore_terminal_title`] can pop it back + /// on exit instead of leaving a blank title. + fn push_user_terminal_title_once(&mut self) -> std::io::Result<()> { + if self.terminal_title_pushed { + return Ok(()); + } + push_terminal_title()?; + self.terminal_title_pushed = true; + Ok(()) + } + + /// Restores the title saved by [`push_user_terminal_title_once`]. + /// + /// Called from `App`'s drop. A second pop is a no-op once the stack entry + /// has been consumed, so a later drop of the same widget does not pop a + /// title Codex never pushed. + pub(crate) fn restore_terminal_title(&mut self) -> std::io::Result<()> { + if !self.terminal_title_pushed { + return Ok(()); + } + pop_terminal_title()?; + self.terminal_title_pushed = false; + self.last_terminal_title = None; + Ok(()) + } + /// Renders and applies the terminal title for one parsed selection snapshot. /// /// Empty selections clear the managed title. Non-empty selections render the @@ -253,6 +282,11 @@ impl ChatWidget { if self.app_event_tx.voice_only.load(Ordering::Relaxed) { return; } + if !selections.terminal_title_items.is_empty() + && let Err(err) = self.push_user_terminal_title_once() + { + tracing::debug!(error = %err, "failed to push terminal title"); + } self.last_terminal_title_requires_action = self.terminal_title_shows_action_required_with_selections(selections); let now = Instant::now(); @@ -852,6 +886,7 @@ impl ChatWidget { StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(), StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot, StatusSurfacePreviewItem::Status => return Some(self.run_state_status_text()), + StatusSurfacePreviewItem::AgentState => return Some(self.agent_state_title_text()), StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::Hostname => StatusLineItem::Hostname, @@ -902,6 +937,12 @@ impl ChatWidget { )), TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), TerminalTitleItem::Status => Some(self.run_state_status_text()), + // The whole title, so a session manager can match one stable string + // without parsing `run-state`. Approval wins; a task that is not + // running (the same cases `run-state` labels Ready) is ready; + // Working, Thinking, and Waiting collapse to working. No error + // variant exists on the status kind, so none is invented here. + TerminalTitleItem::AgentState => Some(self.agent_state_title_text()), TerminalTitleItem::Thread | TerminalTitleItem::ThreadName => { let item = if item == TerminalTitleItem::ThreadName { StatusLineItem::ThreadName @@ -1022,6 +1063,23 @@ impl ChatWidget { } } + /// Whole-title agent state written as OSC 2 by the `agent-state` item. + /// + /// Written only when the rendered title changes (see + /// `refresh_terminal_title_from_selections`). This item never schedules an + /// animation refresh, so an idle prompt with `animations = false` and no + /// `activity` or thread-name items emits no further title bytes. + pub(super) fn agent_state_title_text(&self) -> String { + if self.terminal_title_requires_action() { + return "codex: awaiting approval".to_string(); + } + if self.run_state_status_text() == "Ready" { + "codex: ready".to_string() + } else { + "codex: working".to_string() + } + } + pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { let spinner = (self.local_settings.tui.animations && self.local_settings.tui.effects.progress diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index abf13c77866a..77b8e720667d 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -382,6 +382,75 @@ async fn thread_title_progress_animates_when_main_turn_is_idle() { assert!(chat.terminal_title_next_refresh.is_none()); } +fn exec_approval(call_id: &str) -> ExecApprovalRequestEvent { + ExecApprovalRequestEvent { + kind: Default::default(), + call_id: call_id.into(), + approval_id: Some(call_id.into()), + turn_id: format!("turn-{call_id}"), + environment_id: None, + command: vec!["bash".into(), "-lc".into(), "echo hello".into()], + cwd: AbsolutePathBuf::current_dir().expect("current dir"), + reason: Some("need confirmation".into()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: None, + } +} + +#[tokio::test] +async fn agent_state_title_reports_ready_working_and_awaiting_approval() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.animations = false; + chat.local_settings.tui.terminal_title = Some(vec!["agent-state".into()]); + chat.bottom_pane.set_task_running(/*running*/ false); + chat.refresh_terminal_title(); + assert_eq!(chat.last_terminal_title.as_deref(), Some("codex: ready")); + assert!(chat.terminal_title_next_refresh.is_none()); + + chat.bottom_pane.set_task_running(/*running*/ true); + chat.refresh_terminal_title(); + assert_eq!(chat.last_terminal_title.as_deref(), Some("codex: working")); + assert!(chat.terminal_title_next_refresh.is_none()); + + handle_exec_approval_request(&mut chat, "sub-agent-state", exec_approval("call-agent-state")); + chat.refresh_terminal_title(); + assert_eq!( + chat.last_terminal_title.as_deref(), + Some("codex: awaiting approval") + ); + // Approval with only `agent-state` does not blink, so an idle loop writes nothing. + assert!(chat.terminal_title_next_refresh.is_none()); + + // A second refresh of the same state writes no further title bytes. + let written = chat.last_terminal_title.clone(); + chat.refresh_terminal_title(); + assert_eq!(chat.last_terminal_title, written); + assert!(chat.terminal_title_next_refresh.is_none()); +} + +#[tokio::test] +async fn agent_state_title_is_restored_on_exit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.animations = false; + chat.local_settings.tui.terminal_title = Some(vec!["agent-state".into()]); + chat.refresh_terminal_title(); + assert!(chat.terminal_title_pushed); + assert_eq!(chat.last_terminal_title.as_deref(), Some("codex: ready")); + + chat.restore_terminal_title() + .expect("pop the saved title"); + assert!(!chat.terminal_title_pushed); + assert_eq!(chat.last_terminal_title, None); + + // Dropping twice must not pop a title this widget never pushed. + chat.restore_terminal_title() + .expect("second restore is a no-op"); + assert!(!chat.terminal_title_pushed); +} + #[tokio::test] async fn thread_title_progress_preserves_suffix_after_truncation_and_in_default_footer() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/terminal_title.rs b/codex-rs/tui/src/terminal_title.rs index ff2195ee5086..b930fff66c97 100644 --- a/codex-rs/tui/src/terminal_title.rs +++ b/codex-rs/tui/src/terminal_title.rs @@ -4,8 +4,11 @@ //! that happens immediately before we emit it. It is intentionally narrow: //! callers decide when the title should change and whether an empty title means //! "leave the old title alone" or "clear the title Codex last wrote". -//! This module does not attempt to read or restore the terminal's previous -//! title because that is not portable across terminals. +//! Reading the previous title is not portable. Restoring it is: xterm's window +//! operations push (`CSI 22 ; 0 t`) and pop (`CSI 23 ; 0 t`) the title stack +//! without Codex ever learning what the title was. The TUI pushes once before +//! the first title write and pops on exit, so the user's title comes back +//! instead of being blanked. //! //! Sanitization is necessary because title content is assembled from untrusted //! text sources such as model output, thread names, project paths, and config. @@ -85,6 +88,30 @@ pub(crate) fn set_terminal_title(title: &str) -> io::Result io::Result<()> { + if !stdout().is_terminal() { + return Ok(()); + } + execute!(stdout(), PushWindowTitle) +} + +/// Pops the window title stack (`CSI 23 ; 0 t`), restoring the title saved by +/// [`push_terminal_title`]. +/// +/// This is the exit path. It does not write an empty OSC title: blanking the +/// title leaves the user's shell prompt with no title at all. +pub(crate) fn pop_terminal_title() -> io::Result<()> { + if !stdout().is_terminal() { + return Ok(()); + } + execute!(stdout(), PopWindowTitle) +} + /// Clears the current terminal title by writing an empty OSC title payload. /// /// This clears the visible title; it does not restore whatever title the shell @@ -97,6 +124,44 @@ pub(crate) fn clear_terminal_title() -> io::Result<()> { execute!(stdout(), SetWindowTitle(String::new())) } +/// `CSI 22 ; 0 t` — push icon and window titles. +struct PushWindowTitle; + +impl Command for PushWindowTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[22;0t") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +/// `CSI 23 ; 0 t` — pop icon and window titles. +struct PopWindowTitle; + +impl Command for PopWindowTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[23;0t") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Ok(()) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + #[derive(Debug, Clone)] struct SetWindowTitle(String); @@ -210,6 +275,8 @@ fn is_disallowed_terminal_title_char(ch: char) -> bool { #[cfg(test)] mod tests { use super::MAX_TERMINAL_TITLE_CHARS; + use super::PopWindowTitle; + use super::PushWindowTitle; use super::SetWindowTitle; use super::TitleEncoding; use super::sanitize_terminal_title; @@ -249,6 +316,20 @@ mod tests { assert_eq!(sanitized.chars().last(), Some('b')); } + #[test] + fn push_and_pop_are_xterm_title_stack_operations() { + let mut pushed = String::new(); + PushWindowTitle + .write_ansi(&mut pushed) + .expect("encode title push"); + let mut popped = String::new(); + PopWindowTitle + .write_ansi(&mut popped) + .expect("encode title pop"); + assert_eq!(pushed, "\x1b[22;0t"); + assert_eq!(popped, "\x1b[23;0t"); + } + #[test] fn writes_osc_title_with_bel_terminator() { let mut out = String::new(); From b5f9b01471f4e53e6d236f2b1fdc299c7ba91cec Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:57:46 +0000 Subject: [PATCH 02/12] tui: add the AgentState terminal title item (WI-1127) Committed by oversight for the worker (git guard refused the path). Co-Authored-By: Claude Opus 5.5 --- codex-rs/tui/src/bottom_pane/title_setup.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/title_setup.rs b/codex-rs/tui/src/bottom_pane/title_setup.rs index ebfc53ca971f..4b61baf7e7f5 100644 --- a/codex-rs/tui/src/bottom_pane/title_setup.rs +++ b/codex-rs/tui/src/bottom_pane/title_setup.rs @@ -50,6 +50,11 @@ pub(crate) enum TerminalTitleItem { /// Compact runtime run-state text. #[strum(to_string = "run-state", serialize = "status")] Status, + /// Whole-title agent state for a session manager: `codex: ready|working|awaiting approval`. + /// + /// Unlike `run-state`, this item is the entire title. It does not blink and + /// does not schedule a refresh while the session is idle. + AgentState, /// Current thread name, omitted when unnamed. ThreadName, /// Current thread title (if available). @@ -108,6 +113,9 @@ impl TerminalTitleItem { TerminalTitleItem::Status => { "Compact session run-state text (Ready, Working, Thinking)" } + TerminalTitleItem::AgentState => { + "Stable agent state for a session manager (codex: ready, working, or awaiting approval)" + } TerminalTitleItem::ThreadName => "Current thread name (omitted when unnamed)", TerminalTitleItem::Thread => "Current thread title, or thread identifier when unnamed", TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)", @@ -154,6 +162,7 @@ impl TerminalTitleItem { TerminalTitleItem::CurrentDir => Some(StatusSurfacePreviewItem::CurrentDir), TerminalTitleItem::Spinner => None, TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status), + TerminalTitleItem::AgentState => Some(StatusSurfacePreviewItem::AgentState), TerminalTitleItem::ThreadName => Some(StatusSurfacePreviewItem::ThreadName), TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle), TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch), From 39ccf9f0b5e752394c36ea73f2ec90cc36b94b37 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:58:29 +0000 Subject: [PATCH 03/12] FORK.md: record the agent-state title commit b5f9b014 is the branch head that carries the Klaudia title contract. --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index 6dfbc071e027..3daddf8f5d3b 100644 --- a/FORK.md +++ b/FORK.md @@ -49,7 +49,7 @@ Record the new base tag and any dropped or reworked patch in the table below. | Commit | What | Upstream status | |---|---|---| -| — | `agent-state` terminal title item (`codex: ready\|working\|awaiting approval`), written only on change, with the user's title pushed (`CSI 22;0t`) at the first title write and popped (`CSI 23;0t`) on exit. Idle with `animations = false` schedules no refresh. SHA filled once the branch commit lands. | fork-only; Klaudia contract from vogt `internal/tui/idle.go` | +| — | `agent-state` terminal title item (`codex: ready\|working\|awaiting approval`), written only on change, with the user's title pushed (`CSI 22;0t`) at the first title write and popped (`CSI 23;0t`) on exit. Idle with `animations = false` schedules no refresh. Landed on `fork/wi-1127-agent-state` at `b5f9b014`. | fork-only; Klaudia contract from vogt `internal/tui/idle.go` | ## External adapters From c975c2cd09a6cf4b0dc750c3236195bdb0729663 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:57:48 +0000 Subject: [PATCH 04/12] model-provider-info: accept wire_api = "chat" again (WI-1286) The fork rejected the chat wire at deserialize time. grok over the Responses API returns an empty message and no function_call, so a provider that speaks Chat Completions needs the wire value back. The ollama-chat provider id stays rejected, and the remote thread-config proto still only carries Responses. --- codex-rs/config/src/thread_config/remote.rs | 4 ++++ codex-rs/core/src/client.rs | 5 +++++ codex-rs/model-provider-info/src/lib.rs | 15 ++++++++++++--- .../src/model_provider_info_tests.rs | 7 ++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs index da0af3bca082..f17fd331864b 100644 --- a/codex-rs/config/src/thread_config/remote.rs +++ b/codex-rs/config/src/thread_config/remote.rs @@ -163,6 +163,8 @@ fn model_provider_from_proto( Ok(proto::WireApi::Unspecified) => { return Err(parse_error("remote thread config omitted wire_api")); } + // Remote thread config has no chat variant. A local `wire_api = "chat"` + // provider cannot round-trip through it, and an unknown value is an error. Err(_) => { return Err(parse_error(format!( "remote thread config returned unknown wire_api: {}", @@ -314,6 +316,8 @@ fn proto_string_map(values: HashMap) -> proto::StringMap fn proto_wire_api(wire_api: WireApi) -> proto::WireApi { match wire_api { WireApi::Responses => proto::WireApi::Responses, + // No proto variant. Tests only round-trip Responses providers. + WireApi::Chat => proto::WireApi::Unspecified, } } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 24f2964ffe56..37478b6da333 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -2284,6 +2284,11 @@ impl ModelClientSession { ) .await } + // The Chat Completions request and SSE parser land in the next step. + WireApi::Chat => Err(ApiError::Stream( + "wire_api = \"chat\" is accepted but the chat completions client is not wired yet" + .to_string(), + )), } } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index f020d34c2541..c068af4a0708 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -97,7 +97,6 @@ pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"; const AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER: &str = "x-amzn-mantle-client-agent"; const AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE: &str = "codex"; -const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782"; pub const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; pub const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782"; @@ -108,12 +107,19 @@ pub enum WireApi { /// The Responses API exposed by OpenAI at `/v1/responses`. #[default] Responses, + /// The Chat Completions API at `/v1/chat/completions`. + /// + /// Restored in this fork so a provider that does not return tool calls over + /// the Responses API (grok through LiteLLM, DLab, OpenRouter) can still drive + /// function tools. The built-in `ollama-chat` provider id stays rejected. + Chat, } impl fmt::Display for WireApi { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match self { Self::Responses => "responses", + Self::Chat => "chat", }; f.write_str(value) } @@ -127,8 +133,11 @@ impl<'de> Deserialize<'de> for WireApi { let value = String::deserialize(deserializer)?; match value.as_str() { "responses" => Ok(Self::Responses), - "chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)), - _ => Err(serde::de::Error::unknown_variant(&value, &["responses"])), + "chat" => Ok(Self::Chat), + _ => Err(serde::de::Error::unknown_variant( + &value, + &["responses", "chat"], + )), } } } diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 9c15c54b70d1..f653eb08a087 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -187,7 +187,7 @@ supports_standalone_web_search = true } #[test] -fn test_deserialize_chat_wire_api_shows_helpful_error() { +fn test_deserialize_chat_wire_api() { let provider_toml = r#" name = "OpenAI using Chat Completions" base_url = "https://api.openai.com/v1" @@ -195,8 +195,9 @@ env_key = "OPENAI_API_KEY" wire_api = "chat" "#; - let err = toml::from_str::(provider_toml).unwrap_err(); - assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR)); + let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap(); + assert_eq!(provider.wire_api, WireApi::Chat); + assert_eq!(provider.wire_api.to_string(), "chat"); } #[test] From 7b6f94ff4c972e74745ec879ec2b9dbe49373fd6 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:40:24 +0000 Subject: [PATCH 05/12] core: Chat Completions request conversion for wire_api = "chat" (WI-1286) Flatten namespaced tools to namespace__name, expose freeform tools (apply_patch) as a one-string-argument function, and keep a ChatToolMap to restore returned calls to FunctionCall/CustomToolCall items. Co-Authored-By: Claude Opus 5.5 --- codex-rs/core/src/chat_request.rs | 606 ++++++++++++++++++++++++++++++ codex-rs/core/src/client.rs | 5 +- codex-rs/core/src/lib.rs | 1 + 3 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/src/chat_request.rs diff --git a/codex-rs/core/src/chat_request.rs b/codex-rs/core/src/chat_request.rs new file mode 100644 index 000000000000..9f2e328a161c --- /dev/null +++ b/codex-rs/core/src/chat_request.rs @@ -0,0 +1,606 @@ +//! Chat Completions request body for `wire_api = "chat"` (fork-only, WI-1286). +//! +//! This is the request half of the Chat Completions path. The HTTP client and +//! SSE parser live in `codex-api`; the conversion lives here because `Prompt` +//! and `ResponseItem` do. +//! +//! Chat Completions only knows JSON-schema function tools, so namespaced tools +//! are flattened to `namespace__name` and freeform tools (apply_patch) become a +//! function taking one string argument, `input`. [`ChatToolMap`] records that +//! flattening so tool calls coming back can be restored to the items the rest +//! of Codex expects. + +use crate::client_common::Prompt; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItem; +use codex_tools::FreeformTool; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::Value; +use serde_json::json; +use std::collections::HashMap; + +/// Argument name carrying a freeform tool's raw input over Chat Completions. +const FREEFORM_INPUT_ARG: &str = "input"; + +/// Flatten a namespaced tool to a single Chat Completions function name. +/// Chat Completions has no namespace, so the namespace is part of the name. +pub(crate) fn chat_tool_name(namespace: Option<&str>, name: &str) -> String { + match namespace { + Some(namespace) if !namespace.is_empty() => format!("{namespace}__{name}"), + _ => name.to_string(), + } +} + +#[derive(Debug, Clone, PartialEq)] +struct ChatTool { + name: String, + namespace: Option, + freeform: bool, +} + +/// Maps the flattened Chat Completions function names of one request back to +/// the Codex tools they stand for. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct ChatToolMap { + tools: HashMap, +} + +impl ChatToolMap { + fn insert(&mut self, namespace: Option<&str>, name: &str, freeform: bool) -> String { + let chat_name = chat_tool_name(namespace, name); + self.tools.insert( + chat_name.clone(), + ChatTool { + name: name.to_string(), + namespace: namespace.map(str::to_string), + freeform, + }, + ); + chat_name + } + + /// Restore a function call parsed from a chat stream to the item Codex + /// would have received over the Responses API: the original name and + /// namespace, and a `CustomToolCall` for freeform tools. + pub(crate) fn restore(&self, item: ResponseItem) -> ResponseItem { + let ResponseItem::FunctionCall { + id, + name, + arguments, + call_id, + .. + } = &item + else { + return item; + }; + let Some(tool) = self.tools.get(name) else { + return item; + }; + if tool.freeform { + return ResponseItem::CustomToolCall { + id: id.clone(), + status: None, + call_id: call_id.clone(), + name: tool.name.clone(), + namespace: tool.namespace.clone(), + input: freeform_input(arguments), + internal_chat_message_metadata_passthrough: None, + }; + } + ResponseItem::FunctionCall { + id: id.clone(), + name: tool.name.clone(), + namespace: tool.namespace.clone(), + arguments: arguments.clone(), + encrypted_function_args: None, + call_id: call_id.clone(), + internal_chat_message_metadata_passthrough: None, + } + } +} + +/// A model may answer a single-string function with the bare string rather +/// than `{"input": ...}`; accept both. +fn freeform_input(arguments: &str) -> String { + match serde_json::from_str::(arguments) { + Ok(Value::Object(map)) => match map.get(FREEFORM_INPUT_ARG) { + Some(Value::String(input)) => input.clone(), + _ => arguments.to_string(), + }, + Ok(Value::String(input)) => input, + _ => arguments.to_string(), + } +} + +/// Build the JSON body posted to `/chat/completions`, and the map that turns +/// the tool calls in its response back into Codex tool calls. +/// +/// Responses-only items (`Reasoning`, `WebSearch`, tool search, …) are +/// dropped rather than failing the turn: a chat provider cannot represent them. +pub(crate) fn build_chat_request(model: &str, prompt: &Prompt) -> (Value, ChatToolMap) { + let mut messages = Vec::new(); + if !prompt.base_instructions.text.is_empty() { + messages.push(json!({ + "role": "system", + "content": prompt.base_instructions.text, + })); + } + for item in &prompt.input { + push_item(&mut messages, item); + } + + let mut body = json!({ + "model": model, + "messages": messages, + "stream": true, + "stream_options": { "include_usage": true }, + }); + let mut tool_map = ChatToolMap::default(); + let tools = chat_tools(prompt.tools.as_ref(), &mut tool_map); + if !tools.is_empty() { + body["tools"] = Value::Array(tools); + body["parallel_tool_calls"] = Value::Bool(prompt.parallel_tool_calls); + } + (body, tool_map) +} + +fn push_item(messages: &mut Vec, item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + let content = message_content(content); + if content.is_empty() { + return; + } + // Chat Completions has no developer role on most providers. + let role = if role == "developer" { "system" } else { role }; + messages.push(json!({ "role": role, "content": content })); + } + ResponseItem::FunctionCall { + name, + namespace, + arguments, + call_id, + .. + } => push_tool_call( + messages, + call_id, + chat_tool_name(namespace.as_deref(), name), + arguments.clone(), + ), + ResponseItem::CustomToolCall { + name, + namespace, + input, + call_id, + .. + } => push_tool_call( + messages, + call_id, + chat_tool_name(namespace.as_deref(), name), + json!({ FREEFORM_INPUT_ARG: input }).to_string(), + ), + ResponseItem::FunctionCallOutput { + call_id, output, .. + } => { + if let Some(call_id) = call_id.as_deref() { + push_tool_output(messages, call_id, output); + } + } + ResponseItem::CustomToolCallOutput { + call_id, output, .. + } => push_tool_output(messages, call_id, output), + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::ConfigurationUpdate { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => {} + } +} + +/// Consecutive tool calls become one assistant message with several +/// `tool_calls`, which is how Chat Completions represents a parallel call. +/// A call straight after assistant text joins that message too. +fn push_tool_call(messages: &mut Vec, call_id: &str, name: String, arguments: String) { + let call = json!({ + "id": call_id, + "type": "function", + "function": { "name": name, "arguments": arguments }, + }); + if let Some(last) = messages.last_mut() + && last.get("role").and_then(Value::as_str) == Some("assistant") + { + match last.get_mut("tool_calls").and_then(Value::as_array_mut) { + Some(calls) => calls.push(call), + None => last["tool_calls"] = Value::Array(vec![call]), + } + return; + } + messages.push(json!({ + "role": "assistant", + "content": Value::Null, + "tool_calls": [call], + })); +} + +fn push_tool_output(messages: &mut Vec, call_id: &str, output: &FunctionCallOutputPayload) { + if call_id.is_empty() { + return; + } + messages.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "content": tool_output_text(output), + })); +} + +fn message_content(content: &[ContentItem]) -> String { + content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Some(text.as_str()) + } + ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => None, + }) + .collect::>() + .join("\n") +} + +fn tool_output_text(output: &FunctionCallOutputPayload) -> String { + output + .text_content() + .map(str::to_string) + .or_else(|| output.body.to_text()) + .unwrap_or_default() +} + +fn chat_tools(tools: &[ToolSpec], map: &mut ChatToolMap) -> Vec { + let mut out = Vec::new(); + for tool in tools { + match tool { + ToolSpec::Function(function) => out.push(function_tool(None, function, map)), + ToolSpec::Freeform(freeform) => out.push(freeform_tool(None, freeform, map)), + ToolSpec::Namespace(namespace) => { + let ns = Some(namespace.name.as_str()); + for tool in &namespace.tools { + out.push(match tool { + ResponsesApiNamespaceTool::Function(function) => { + function_tool(ns, function, map) + } + ResponsesApiNamespaceTool::Custom(freeform) => { + freeform_tool(ns, freeform, map) + } + }); + } + } + ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => {} + } + } + out +} + +fn function_tool( + namespace: Option<&str>, + function: &ResponsesApiTool, + map: &mut ChatToolMap, +) -> Value { + json!({ + "type": "function", + "function": { + "name": map.insert(namespace, &function.name, /*freeform*/ false), + "description": function.description, + "parameters": function.parameters, + }, + }) +} + +fn freeform_tool(namespace: Option<&str>, tool: &FreeformTool, map: &mut ChatToolMap) -> Value { + let input_description = format!( + "The raw tool input, written in this {} grammar:\n{}", + tool.format.syntax, tool.format.definition + ); + json!({ + "type": "function", + "function": { + "name": map.insert(namespace, &tool.name, /*freeform*/ true), + "description": tool.description, + "parameters": { + "type": "object", + "properties": { + FREEFORM_INPUT_ARG: { "type": "string", "description": input_description }, + }, + "required": [FREEFORM_INPUT_ARG], + "additionalProperties": false, + }, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::build_chat_request; + use crate::client_common::Prompt; + use codex_protocol::models::BaseInstructions; + use codex_protocol::models::ContentItem; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::ResponseItem; + use codex_tools::FreeformTool; + use codex_tools::FreeformToolFormat; + use codex_tools::JsonSchema; + use codex_tools::ResponsesApiNamespace; + use codex_tools::ResponsesApiNamespaceTool; + use codex_tools::ResponsesApiTool; + use codex_tools::ToolSpec; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + + fn function(name: &str) -> ResponsesApiTool { + ResponsesApiTool { + name: name.to_string(), + description: format!("{name} description"), + strict: false, + defer_loading: None, + parameters: JsonSchema::default(), + output_schema: None, + } + } + + #[test] + fn chat_request_converts_messages_tools_and_parallel_calls() { + let prompt = Prompt { + base_instructions: BaseInstructions { + text: "be brief".to_string(), + provenance: None, + }, + input: vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "list files".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{\"command\":\"ls\"}".to_string(), + encrypted_function_args: None, + call_id: "call-1".to_string(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "work_get".to_string(), + namespace: Some("vogt".to_string()), + arguments: "{\"ref\":\"WI-1\"}".to_string(), + encrypted_function_args: None, + call_id: "call-2".to_string(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call-1".to_string()), + name: Some("shell".to_string()), + namespace: None, + output: FunctionCallOutputPayload::from_text("a.rs".to_string()), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Reasoning { + id: None, + summary: Vec::new(), + content: None, + encrypted_content: None, + internal_chat_message_metadata_passthrough: None, + }, + ], + tools: Arc::from(vec![ + ToolSpec::Function(function("shell")), + ToolSpec::Namespace(ResponsesApiNamespace { + name: "vogt".to_string(), + description: "vogt".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(function("work_get"))], + }), + ]), + parallel_tool_calls: true, + output_schema: None, + output_schema_strict: true, + cyber_access_program: None, + }; + + assert_eq!( + build_chat_request("grok", &prompt).0, + json!({ + "model": "grok", + "stream": true, + "stream_options": { "include_usage": true }, + "parallel_tool_calls": true, + "messages": [ + { "role": "system", "content": "be brief" }, + { "role": "user", "content": "list files" }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { "name": "shell", "arguments": "{\"command\":\"ls\"}" } + }, + { + "id": "call-2", + "type": "function", + "function": { "name": "vogt__work_get", "arguments": "{\"ref\":\"WI-1\"}" } + } + ] + }, + { "role": "tool", "tool_call_id": "call-1", "content": "a.rs" } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "shell", + "description": "shell description", + "parameters": {} + } + }, + { + "type": "function", + "function": { + "name": "vogt__work_get", + "description": "work_get description", + "parameters": {} + } + } + ] + }) + ); + } + + fn prompt_with(input: Vec, tools: Vec) -> Prompt { + Prompt { + base_instructions: BaseInstructions { + text: String::new(), + provenance: None, + }, + input, + tools: Arc::from(tools), + parallel_tool_calls: false, + output_schema: None, + output_schema_strict: true, + cyber_access_program: None, + } + } + + fn apply_patch_tool() -> ToolSpec { + ToolSpec::Freeform(FreeformTool { + name: "apply_patch".to_string(), + description: "edit files".to_string(), + defer_loading: None, + format: FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: patch".to_string(), + }, + }) + } + + #[test] + fn freeform_tool_round_trips_as_single_string_function() { + let prompt = prompt_with( + vec![ + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-p".to_string(), + name: "apply_patch".to_string(), + namespace: None, + input: "*** Begin Patch".to_string(), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::CustomToolCallOutput { + id: None, + call_id: "call-p".to_string(), + name: None, + output: FunctionCallOutputPayload::from_text("Done".to_string()), + internal_chat_message_metadata_passthrough: None, + }, + ], + vec![apply_patch_tool()], + ); + let (body, map) = build_chat_request("grok", &prompt); + assert_eq!( + body["messages"], + json!([ + { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call-p", + "type": "function", + "function": { "name": "apply_patch", "arguments": "{\"input\":\"*** Begin Patch\"}" } + }] + }, + { "role": "tool", "tool_call_id": "call-p", "content": "Done" } + ]) + ); + assert_eq!( + body["tools"][0]["function"]["parameters"]["required"], + json!(["input"]) + ); + + let call = |arguments: &str| ResponseItem::FunctionCall { + id: None, + name: "apply_patch".to_string(), + namespace: None, + arguments: arguments.to_string(), + encrypted_function_args: None, + call_id: "call-q".to_string(), + internal_chat_message_metadata_passthrough: None, + }; + let expected = ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-q".to_string(), + name: "apply_patch".to_string(), + namespace: None, + input: "*** Begin Patch".to_string(), + internal_chat_message_metadata_passthrough: None, + }; + assert_eq!( + map.restore(call("{\"input\":\"*** Begin Patch\"}")), + expected + ); + assert_eq!(map.restore(call("\"*** Begin Patch\"")), expected); + } + + #[test] + fn namespaced_mcp_tool_call_is_restored() { + let prompt = prompt_with( + Vec::new(), + vec![ToolSpec::Namespace(ResponsesApiNamespace { + name: "mcp__vogt".to_string(), + description: "vogt".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(function("work_get"))], + })], + ); + let (body, map) = build_chat_request("grok", &prompt); + assert_eq!(body["tools"][0]["function"]["name"], "mcp__vogt__work_get"); + let restored = map.restore(ResponseItem::FunctionCall { + id: None, + name: "mcp__vogt__work_get".to_string(), + namespace: None, + arguments: "{}".to_string(), + encrypted_function_args: None, + call_id: "call-m".to_string(), + internal_chat_message_metadata_passthrough: None, + }); + assert_eq!( + restored, + ResponseItem::FunctionCall { + id: None, + name: "work_get".to_string(), + namespace: Some("mcp__vogt".to_string()), + arguments: "{}".to_string(), + encrypted_function_args: None, + call_id: "call-m".to_string(), + internal_chat_message_metadata_passthrough: None, + } + ); + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 37478b6da333..0eab6b4a29b1 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -2285,10 +2285,11 @@ impl ModelClientSession { .await } // The Chat Completions request and SSE parser land in the next step. - WireApi::Chat => Err(ApiError::Stream( + WireApi::Chat => Err(codex_protocol::error::CodexErrorDetails::Stream( "wire_api = \"chat\" is accepted but the chat completions client is not wired yet" .to_string(), - )), + ) + .into()), } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index dbd190343b95..0e21098ee12e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -7,6 +7,7 @@ mod apply_patch; mod apps; +mod chat_request; mod client; mod client_common; mod model_request; From ccfbabd6dc2301402b990719f6b9fdf770e3ced4 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:45:49 +0000 Subject: [PATCH 06/12] codex-api: Chat Completions client and SSE parser (WI-1286) ChatClient posts to /chat/completions; process_chat_sse maps chunks to ResponseEvents: text deltas, an assistant message, one FunctionCall per tool call (parallel calls split by index) and Completed with usage. Fixture tests cover text, single, parallel, apply_patch and MCP calls. Co-Authored-By: Claude Opus 5.5 --- codex-rs/codex-api/src/endpoint/chat.rs | 72 ++++++ codex-rs/codex-api/src/endpoint/mod.rs | 2 + codex-rs/codex-api/src/lib.rs | 1 + codex-rs/codex-api/src/sse/chat.rs | 304 +++++++++++++++++++++++ codex-rs/codex-api/src/sse/chat_tests.rs | 159 ++++++++++++ codex-rs/codex-api/src/sse/mod.rs | 2 + 6 files changed, 540 insertions(+) create mode 100644 codex-rs/codex-api/src/endpoint/chat.rs create mode 100644 codex-rs/codex-api/src/sse/chat.rs create mode 100644 codex-rs/codex-api/src/sse/chat_tests.rs diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/chat.rs new file mode 100644 index 000000000000..8ffe994079cf --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/chat.rs @@ -0,0 +1,72 @@ +//! Chat Completions HTTP client for `wire_api = "chat"` (fork-only, WI-1286). + +use crate::auth::SharedAuthProvider; +use crate::common::ResponseStream; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::sse::spawn_chat_stream; +use crate::telemetry::SseTelemetry; +use codex_client::EncodedJsonBody; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::HeaderValue; +use http::Method; +use serde_json::Value; +use std::sync::Arc; + +pub struct ChatClient { + session: EndpointSession, + sse_telemetry: Option>, +} + +impl ChatClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + sse_telemetry: None, + } + } + + pub fn with_telemetry( + self, + request: Option>, + sse: Option>, + ) -> Self { + Self { + session: self.session.with_request_telemetry(request), + sse_telemetry: sse, + } + } + + /// POST a chat request body to `/chat/completions` and stream the reply. + pub async fn stream( + &self, + body: &Value, + extra_headers: HeaderMap, + ) -> Result { + let body = EncodedJsonBody::encode(body) + .map_err(|e| ApiError::Stream(format!("failed to encode chat request: {e}")))?; + let stream_response = self + .session + .stream_encoded_json_with( + Method::POST, + "/chat/completions", + extra_headers, + Some(body), + |req| { + req.headers.insert( + http::header::ACCEPT, + HeaderValue::from_static("text/event-stream"), + ); + }, + ) + .await?; + Ok(spawn_chat_stream( + stream_response, + self.session.provider().stream_idle_timeout, + self.sse_telemetry.clone(), + )) + } +} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 77b3054aeeca..7f44fac23f5d 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod chat; pub(crate) mod images; pub(crate) mod memories; pub(crate) mod models; @@ -8,6 +9,7 @@ pub(crate) mod responses_websocket; pub(crate) mod search; mod session; +pub use chat::ChatClient; pub use images::ImageRequestError; pub use images::ImagesClient; pub use memories::MemoriesClient; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index b7511d9e50f4..0f13ecaa828e 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -50,6 +50,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY; pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; +pub use crate::endpoint::ChatClient; pub use crate::endpoint::ImageRequestError; pub use crate::endpoint::ImagesClient; pub use crate::endpoint::MemoriesClient; diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs new file mode 100644 index 000000000000..784825f3ac81 --- /dev/null +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -0,0 +1,304 @@ +//! Chat Completions SSE parser for `wire_api = "chat"` (fork-only, WI-1286). +//! +//! Turns `chat.completion.chunk` events into the same [`ResponseEvent`]s the +//! Responses parser emits: text deltas, one assistant message, one +//! `FunctionCall` item per tool call (parallel calls arrive interleaved by +//! `index`), and a final `Completed` carrying usage. Tool names come back as +//! the flattened chat names; the caller restores namespaces and freeform +//! tools. + +use crate::common::ResponseEvent; +use crate::common::ResponseStream; +use crate::error::ApiError; +use crate::telemetry::SseTelemetry; +use codex_client::ByteStream; +use codex_client::StreamResponse; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio::time::timeout; +use tracing::debug; +use tracing::trace; + +pub fn spawn_chat_stream( + stream_response: StreamResponse, + idle_timeout: Duration, + telemetry: Option>, +) -> ResponseStream { + let upstream_request_id = stream_response + .headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let (tx_event, rx_event) = mpsc::channel::>(1600); + tokio::spawn(process_chat_sse( + stream_response.bytes, + tx_event, + idle_timeout, + telemetry, + )); + ResponseStream { + rx_event, + upstream_request_id, + interrupt: None, + } +} + +#[derive(Default)] +struct ToolCallState { + id: String, + name: String, + arguments: String, +} + +#[derive(Default)] +struct ChatAccumulator { + response_id: Option, + created_sent: bool, + text: String, + message_started: bool, + tool_calls: BTreeMap, + usage: Option, + finished: bool, +} + +impl ChatAccumulator { + /// Apply one chunk, returning the events it produces right away. + fn apply(&mut self, chunk: &Value) -> Result, ApiError> { + if let Some(error) = chunk.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| error.to_string()); + return Err(ApiError::Stream(message)); + } + let mut events = Vec::new(); + if self.response_id.is_none() + && let Some(id) = chunk.get("id").and_then(Value::as_str) + && !id.is_empty() + { + self.response_id = Some(id.to_string()); + } + if !self.created_sent { + self.created_sent = true; + events.push(ResponseEvent::Created { + response_id: self.response_id.clone(), + }); + } + if let Some(usage) = chunk.get("usage").filter(|usage| !usage.is_null()) { + self.usage = Some(token_usage(usage)); + } + let choices = chunk.get("choices").and_then(Value::as_array); + for choice in choices.into_iter().flatten() { + let delta = choice.get("delta"); + if let Some(text) = delta + .and_then(|delta| delta.get("content")) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + if !self.message_started { + self.message_started = true; + events.push(ResponseEvent::OutputItemAdded(assistant_message( + String::new(), + ))); + } + self.text.push_str(text); + events.push(ResponseEvent::OutputTextDelta(text.to_string())); + } + let tool_calls = delta + .and_then(|delta| delta.get("tool_calls")) + .and_then(Value::as_array); + for (position, call) in tool_calls.into_iter().flatten().enumerate() { + let index = call + .get("index") + .and_then(Value::as_u64) + .unwrap_or(position as u64); + let state = self.tool_calls.entry(index).or_default(); + if let Some(id) = call.get("id").and_then(Value::as_str) + && !id.is_empty() + { + state.id = id.to_string(); + } + if let Some(function) = call.get("function") { + if let Some(name) = function.get("name").and_then(Value::as_str) { + state.name.push_str(name); + } + if let Some(arguments) = function.get("arguments").and_then(Value::as_str) { + state.arguments.push_str(arguments); + } + } + } + if choice + .get("finish_reason") + .is_some_and(|reason| !reason.is_null()) + { + self.finished = true; + } + } + Ok(events) + } + + /// Emit the completed items and the terminal `Completed` event. + fn finish(&mut self) -> Vec { + let mut events = Vec::new(); + if !self.created_sent { + events.push(ResponseEvent::Created { response_id: None }); + } + if self.message_started { + events.push(ResponseEvent::OutputItemDone(assistant_message( + std::mem::take(&mut self.text), + ))); + } + let response_id = self.response_id.clone().unwrap_or_default(); + for (index, call) in std::mem::take(&mut self.tool_calls) { + if call.name.is_empty() { + continue; + } + let call_id = if call.id.is_empty() { + format!("call_{response_id}_{index}") + } else { + call.id + }; + let arguments = if call.arguments.is_empty() { + "{}".to_string() + } else { + call.arguments + }; + events.push(ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { + id: None, + name: call.name, + namespace: None, + arguments, + encrypted_function_args: None, + call_id, + internal_chat_message_metadata_passthrough: None, + })); + } + events.push(ResponseEvent::Completed { + response_id, + token_usage: self.usage.take(), + usage_metadata: None, + end_turn: None, + }); + events + } +} + +fn assistant_message(text: String) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { text }], + phase: None, + internal_chat_message_metadata_passthrough: None, + } +} + +fn token_usage(usage: &Value) -> TokenUsage { + let int = |value: Option<&Value>| value.and_then(Value::as_i64).unwrap_or_default(); + let input_tokens = int(usage.get("prompt_tokens")); + let output_tokens = int(usage.get("completion_tokens")); + TokenUsage { + input_tokens, + cached_input_tokens: int(usage.pointer("/prompt_tokens_details/cached_tokens")), + output_tokens, + reasoning_output_tokens: int(usage.pointer("/completion_tokens_details/reasoning_tokens")), + total_tokens: usage + .get("total_tokens") + .and_then(Value::as_i64) + .unwrap_or(input_tokens + output_tokens), + ..TokenUsage::default() + } +} + +pub async fn process_chat_sse( + stream: ByteStream, + tx_event: mpsc::Sender>, + idle_timeout: Duration, + telemetry: Option>, +) { + let mut stream = stream.eventsource(); + let mut state = ChatAccumulator::default(); + loop { + let start = Instant::now(); + let response = tokio::select! { + biased; + _ = tx_event.closed() => return, + response = timeout(idle_timeout, stream.next()) => response, + }; + if let Some(t) = telemetry.as_ref() { + t.on_sse_poll(&response, start.elapsed()); + } + let sse = match response { + Ok(Some(Ok(sse))) => sse, + Ok(Some(Err(e))) => { + debug!("chat SSE error: {e:#}"); + let _ = tx_event.send(Err(ApiError::Stream(e.to_string()))).await; + return; + } + // Some providers close the stream after the last chunk without + // sending `[DONE]`; a finish_reason is enough to call it complete. + Ok(None) if state.finished => break, + Ok(None) => { + let _ = tx_event + .send(Err(ApiError::Stream( + "chat stream closed before finish_reason".into(), + ))) + .await; + return; + } + Err(_) => { + let _ = tx_event + .send(Err(ApiError::Stream("idle timeout waiting for SSE".into()))) + .await; + return; + } + }; + trace!("chat SSE event: {}", &sse.data); + let data = sse.data.trim(); + if data == "[DONE]" { + break; + } + if data.is_empty() { + continue; + } + let chunk: Value = match serde_json::from_str(data) { + Ok(chunk) => chunk, + Err(e) => { + debug!("failed to parse chat SSE chunk: {e}"); + continue; + } + }; + match state.apply(&chunk) { + Ok(events) => { + for event in events { + if tx_event.send(Ok(event)).await.is_err() { + return; + } + } + } + Err(error) => { + let _ = tx_event.send(Err(error)).await; + return; + } + } + } + for event in state.finish() { + if tx_event.send(Ok(event)).await.is_err() { + return; + } + } +} + +#[cfg(test)] +#[path = "chat_tests.rs"] +mod tests; diff --git a/codex-rs/codex-api/src/sse/chat_tests.rs b/codex-rs/codex-api/src/sse/chat_tests.rs new file mode 100644 index 000000000000..54bfbaa2f955 --- /dev/null +++ b/codex-rs/codex-api/src/sse/chat_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use codex_client::TransportError; +use futures::TryStreamExt; +use pretty_assertions::assert_eq; +use tokio_test::io::Builder as IoBuilder; +use tokio_util::io::ReaderStream; + +async fn collect(sse: &str) -> Vec> { + let reader = IoBuilder::new().read(sse.as_bytes()).build(); + let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); + let (tx, mut rx) = mpsc::channel(64); + tokio::spawn(process_chat_sse( + Box::pin(stream), + tx, + Duration::from_secs(1), + /*telemetry*/ None, + )); + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + events +} + +fn done_items(events: &[Result]) -> Vec { + events + .iter() + .filter_map(|event| match event { + Ok(ResponseEvent::OutputItemDone(item)) => Some(item.clone()), + _ => None, + }) + .collect() +} + +fn call(call_id: &str, name: &str, arguments: &str) -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: name.to_string(), + namespace: None, + arguments: arguments.to_string(), + encrypted_function_args: None, + call_id: call_id.to_string(), + internal_chat_message_metadata_passthrough: None, + } +} + +fn assert_completed(events: &[Result]) { + assert!(matches!( + events.last(), + Some(Ok(ResponseEvent::Completed { .. })) + )); +} + +#[tokio::test] +async fn text_stream_emits_deltas_message_and_usage() { + let events = collect(concat!( + "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"}}]}\n\n", + "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"id\":\"c1\",\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12}}\n\n", + "data: [DONE]\n\n", + )) + .await; + let deltas: Vec<_> = events + .iter() + .filter_map(|event| match event { + Ok(ResponseEvent::OutputTextDelta(delta)) => Some(delta.as_str()), + _ => None, + }) + .collect(); + assert_eq!(deltas, vec!["Hel", "lo"]); + assert_eq!( + done_items(&events), + vec![assistant_message("Hello".to_string())] + ); + match events.last() { + Some(Ok(ResponseEvent::Completed { + response_id, + token_usage: Some(usage), + .. + })) => { + assert_eq!(response_id, "c1"); + assert_eq!( + (usage.input_tokens, usage.output_tokens, usage.total_tokens), + (10, 2, 12) + ); + } + other => panic!("expected Completed with usage, got {other:?}"), + } +} + +#[tokio::test] +async fn single_tool_call_arguments_are_concatenated() { + let events = collect(concat!( + "data: {\"id\":\"c2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"\"}}]}}]}\n\n", + "data: {\"id\":\"c2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"command\\\":\"}}]}}]}\n\n", + "data: {\"id\":\"c2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"[\\\"ls\\\"]}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + )) + .await; + assert_eq!( + done_items(&events), + vec![call("call_a", "shell", "{\"command\":[\"ls\"]}")] + ); + assert_completed(&events); +} + +#[tokio::test] +async fn parallel_tool_calls_are_split_by_index() { + let events = collect(concat!( + "data: {\"id\":\"c3\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\":[\\\"pwd\\\"]}\"}},{\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"command\\\"\"}}]}}]}\n\n", + "data: {\"id\":\"c3\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\":[\\\"date\\\"]}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + )) + .await; + assert_eq!( + done_items(&events), + vec![ + call("call_1", "shell", "{\"command\":[\"pwd\"]}"), + call("call_2", "shell", "{\"command\":[\"date\"]}"), + ] + ); +} + +#[tokio::test] +async fn apply_patch_and_mcp_calls_keep_flattened_names() { + // The caller (core ChatToolMap) restores these to CustomToolCall and a + // namespaced FunctionCall; the parser passes the chat names through. + let events = collect(concat!( + "data: {\"id\":\"c4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Editing.\",\"tool_calls\":[{\"index\":0,\"id\":\"call_p\",\"function\":{\"name\":\"apply_patch\",\"arguments\":\"{\\\"input\\\":\\\"*** Begin Patch\\\\n*** End Patch\\\"}\"}}]}}]}\n\n", + "data: {\"id\":\"c4\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_m\",\"function\":{\"name\":\"mcp__vogt__work_get\",\"arguments\":\"{\\\"ref\\\":\\\"WI-1\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + )) + .await; + assert_eq!( + done_items(&events), + vec![ + assistant_message("Editing.".to_string()), + call( + "call_p", + "apply_patch", + "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" + ), + call("call_m", "mcp__vogt__work_get", "{\"ref\":\"WI-1\"}"), + ] + ); + // No [DONE]: finish_reason alone completes the stream. + assert_completed(&events); +} + +#[tokio::test] +async fn error_chunk_and_truncated_stream_fail() { + let events = collect("data: {\"error\":{\"message\":\"rate limited\"}}\n\n").await; + assert!(matches!(events.last(), Some(Err(ApiError::Stream(m))) if m == "rate limited")); + + let events = collect( + "data: {\"id\":\"c5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n", + ) + .await; + assert!(matches!(events.last(), Some(Err(ApiError::Stream(_))))); +} diff --git a/codex-rs/codex-api/src/sse/mod.rs b/codex-rs/codex-api/src/sse/mod.rs index b4872ba6ac66..0be4e0d1ac1a 100644 --- a/codex-rs/codex-api/src/sse/mod.rs +++ b/codex-rs/codex-api/src/sse/mod.rs @@ -1,6 +1,8 @@ +pub(crate) mod chat; pub(crate) mod responses; mod responses_error; +pub use chat::spawn_chat_stream; pub(crate) use responses::ResponsesStreamEvent; pub(crate) use responses::process_responses_event; pub use responses::spawn_response_stream; From 98084e4d6adf3f36fa7c1c3b51f211230c0bd033 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:48:03 +0000 Subject: [PATCH 07/12] core: stream wire_api = "chat" turns through Chat Completions (WI-1286) Co-Authored-By: Claude Opus 5.5 --- codex-rs/core/src/client.rs | 84 ++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 0eab6b4a29b1..318111d24aec 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -41,6 +41,7 @@ use async_channel::Sender; use codex_api::AgentIdentityTelemetry; use codex_api::ApiError; use codex_api::AuthProvider; +use codex_api::ChatClient as ApiChatClient; use codex_api::Compression; use codex_api::MemoriesClient as ApiMemoriesClient; use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput; @@ -1835,6 +1836,79 @@ impl ModelClientSession { } } + /// Streams a turn via Chat Completions (`wire_api = "chat"`, fork-only, + /// WI-1286). Tool calls come back under their flattened chat names and are + /// restored to the items the rest of Codex expects before mapping. + async fn stream_chat_completions( + &self, + prompt: &Prompt, + model_info: &ModelInfo, + session_telemetry: &SessionTelemetry, + inference_trace: &InferenceTraceContext, + ) -> Result { + let client_setup = self + .client + .current_client_setup(ClientRouting::Workspace) + .await?; + tracing::Span::current().record("api.path", "/chat/completions"); + let transport = self.client.build_api_transport( + &client_setup.api_provider, + "/chat/completions", + client_setup.redirect_policy, + )?; + let request_auth_context = AuthRequestTelemetryContext::new( + client_setup.auth.as_ref().map(CodexAuth::auth_mode), + client_setup.api_auth.as_ref(), + client_setup.agent_identity_telemetry.clone(), + PendingUnauthorizedRetry::default(), + ); + let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry( + session_telemetry, + request_auth_context, + RequestRouteTelemetry::for_endpoint("/chat/completions"), + self.client.state.auth_env_telemetry.clone(), + ); + let (body, tool_map) = crate::chat_request::build_chat_request(&model_info.slug, prompt); + let interceptors = crate::model_request::prepare( + &self.client.request_contributors, + &self.client.state.thread_id.to_string(), + &model_info.slug, + codex_extension_api::ModelRequestKind::Generation, + &mut None, + ); + let inference_trace_attempt = inference_trace.start_attempt(); + let client = + ApiChatClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); + let mut api_stream = match client.stream(&body, http::HeaderMap::new()).await { + Ok(stream) => stream, + Err(err) => return Err(self.client.state.provider.map_api_error(err)), + }; + let (tx, rx_event) = mpsc::channel(1600); + let mut upstream = std::mem::replace(&mut api_stream.rx_event, rx_event); + tokio::spawn(async move { + while let Some(event) = upstream.recv().await { + let event = event.map(|event| match event { + codex_api::ResponseEvent::OutputItemDone(item) => { + codex_api::ResponseEvent::OutputItemDone(tool_map.restore(item)) + } + other => other, + }); + if tx.send(event).await.is_err() { + return; + } + } + }); + let (stream, _) = map_response_stream( + api_stream, + session_telemetry.clone(), + inference_trace_attempt, + Arc::clone(&self.client.state.provider), + interceptors, + ); + Ok(stream) + } + /// Streams a turn via the Responses API over WebSocket transport. #[allow(clippy::too_many_arguments)] #[instrument( @@ -2284,12 +2358,10 @@ impl ModelClientSession { ) .await } - // The Chat Completions request and SSE parser land in the next step. - WireApi::Chat => Err(codex_protocol::error::CodexErrorDetails::Stream( - "wire_api = \"chat\" is accepted but the chat completions client is not wired yet" - .to_string(), - ) - .into()), + WireApi::Chat => { + self.stream_chat_completions(prompt, model_info, session_telemetry, inference_trace) + .await + } } } From ee2ad70c4ee317597ee13e50d6f0c7c9dfc9669a Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:48:23 +0000 Subject: [PATCH 08/12] FORK.md: record the wire_api = "chat" patch (WI-1286) Co-Authored-By: Claude Opus 5.5 --- FORK.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FORK.md b/FORK.md index 3daddf8f5d3b..b8fbe1b4e854 100644 --- a/FORK.md +++ b/FORK.md @@ -50,6 +50,7 @@ Record the new base tag and any dropped or reworked patch in the table below. | Commit | What | Upstream status | |---|---|---| | — | `agent-state` terminal title item (`codex: ready\|working\|awaiting approval`), written only on change, with the user's title pushed (`CSI 22;0t`) at the first title write and popped (`CSI 23;0t`) on exit. Idle with `animations = false` schedules no refresh. Landed on `fork/wi-1127-agent-state` at `b5f9b014`. | fork-only; Klaudia contract from vogt `internal/tui/idle.go` | +| — | `wire_api = "chat"`: Chat Completions client restored for providers whose Responses path drops tool calls (grok via theclawbay/LiteLLM, WI-1139). New files only, plus one dispatch arm: `core/src/chat_request.rs` (request conversion; namespaced tools flattened to `ns__name`, freeform tools such as apply_patch exposed as a function taking one `input` string, calls restored by `ChatToolMap`), `codex-api/src/{endpoint,sse}/chat.rs` (client and SSE parser, fixture tests in `sse/chat_tests.rs`). Reasoning, web search and tool search are not sent over chat. Branch `fork/wi-1286-chat-wire`. | fork-only; upstream removed the chat client | ## External adapters From c339f3974fcb759d16781b1d5c34fea9dd619765 Mon Sep 17 00:00:00 2001 From: sprooty Date: Mon, 12 Oct 2026 21:23:56 +0000 Subject: [PATCH 09/12] tui: rustfmt the agent-state title tests (WI-1127) Co-Authored-By: Claude Opus 5.5 --- codex-rs/tui/src/chatwidget/tests/terminal_title.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 77b8e720667d..d95bab6fd5f8 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -415,7 +415,11 @@ async fn agent_state_title_reports_ready_working_and_awaiting_approval() { assert_eq!(chat.last_terminal_title.as_deref(), Some("codex: working")); assert!(chat.terminal_title_next_refresh.is_none()); - handle_exec_approval_request(&mut chat, "sub-agent-state", exec_approval("call-agent-state")); + handle_exec_approval_request( + &mut chat, + "sub-agent-state", + exec_approval("call-agent-state"), + ); chat.refresh_terminal_title(); assert_eq!( chat.last_terminal_title.as_deref(), @@ -440,8 +444,7 @@ async fn agent_state_title_is_restored_on_exit() { assert!(chat.terminal_title_pushed); assert_eq!(chat.last_terminal_title.as_deref(), Some("codex: ready")); - chat.restore_terminal_title() - .expect("pop the saved title"); + chat.restore_terminal_title().expect("pop the saved title"); assert!(!chat.terminal_title_pushed); assert_eq!(chat.last_terminal_title, None); From 7e0316caf23a954f401723d1606102cd86104e60 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:04:08 +0000 Subject: [PATCH 10/12] config: regenerate schema fixture for wire_api = "chat" (WI-1286) config_schema_matches_fixture failed because the restored chat variant was not in the committed schema. Regenerated with codex-write-config-schema. --- codex-rs/core/config.schema.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5eee067c89b9..c01612e66d87 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -6772,6 +6772,13 @@ "responses" ], "type": "string" + }, + { + "description": "The Chat Completions API at `/v1/chat/completions`.\n\nRestored in this fork so a provider that does not return tool calls over the Responses API (grok through LiteLLM, DLab, OpenRouter) can still drive function tools. The built-in `ollama-chat` provider id stays rejected.", + "enum": [ + "chat" + ], + "type": "string" } ] }, From 79ee87ed9e330f51fe35f3567c11cd38ebb323b4 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:04:08 +0000 Subject: [PATCH 11/12] tui: accept the agent-state row in the title setup snapshot (WI-1127) renders_title_setup_popup failed because the new AgentState title item shifted the popup rows. The accepted snapshot adds the agent-state row. --- ...om_pane__title_setup__tests__terminal_title_setup_basic.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap index 21dbeb7627e4..6b3a55fb12af 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -14,8 +14,8 @@ expression: "render_lines(&view, 84)" [x] thread-title Current thread title, or thread identifier when unnamed [ ] app-name Codex app name [ ] current-dir Current working directory + [ ] agent-state Stable agent state for a session manager (codex: ready, working… [ ] thread-name Current thread name (omitted when unnamed) - [ ] git-branch Current Git branch (omitted when unavailable) ↓ [ ! ] Action Required | my-project | Working | thread title space toggle · ←/→ reorder · enter save · esc cancel From 4eb52da46d85784bacfb33973fcf07a039185433 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:46:11 +0000 Subject: [PATCH 12/12] cli: stop the TUI --session-id flag colliding with resume and fork The TUI config struct is flattened into the resume and fork subcommands, which already register a positional argument with clap id session_id. The flattened flag derived the same id from its field name, so clap panicked under debug assertions and `codex resume`/`codex fork` exited 101 before drawing. That is why history_lookup_uses_server_provider_with_local_and_ embedded_servers died waiting for "Server provider history". Give the flag the distinct clap id new_session_id and rename the field to match. The public flag stays --session-id. --- codex-rs/tui/src/cli.rs | 11 +++++++++-- codex-rs/tui/src/lib.rs | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 1a85cf6fc35c..d9f137c623e2 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -61,8 +61,15 @@ pub struct Cli { /// Id for a newly started thread. Any UUID version is accepted. Rejected /// with resume, fork, and ephemeral sessions. - #[arg(long = "session-id", value_name = "UUID")] - pub session_id: Option, + /// + /// The Rust field is named `new_session_id` rather than `session_id`: this + /// struct is flattened into the `resume` and `fork` subcommands, which + /// already register a positional argument whose clap id is `session_id`. + /// Clap rejects the duplicate id at runtime (a panic under debug + /// assertions), so `codex resume` and `codex fork` would exit 101 before + /// drawing anything. + #[arg(long = "session-id", value_name = "UUID", id = "new_session_id")] + pub new_session_id: Option, /// Internal: show all sessions (disables cwd filtering and shows CWD column). #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 38d0f19f3e2e..4e361f43d0a4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1657,7 +1657,7 @@ async fn run_ratatui_app( shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; return Err(err.into()); } - if cli.session_id.is_some() + if cli.new_session_id.is_some() && (config.ephemeral || matches!( &session_selection, @@ -2093,7 +2093,7 @@ async fn run_ratatui_app( startup_draft, managed_worktree, daemon_cli_executable, - cli.session_id.clone(), + cli.new_session_id.clone(), )) .await;