From 14ebfbced9dc502713cf68d457ea78618563b7dc Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 20 Apr 2026 13:43:50 -0700 Subject: [PATCH 1/9] chore(guardian) disable mcps and plugins (#18722) ## Summary Disables apps, plugins, mcps for the guardian subagent thread ## Testing - [x] Added unit tests --- codex-rs/core/src/guardian/review_session.rs | 9 +++++ codex-rs/core/src/guardian/tests.rs | 36 ++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aea5978f1..5bb3e9d1b8 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -732,6 +732,13 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); guardian_config.permissions.sandbox_policy = Constrained::allow_only(SandboxPolicy::new_read_only_policy()); + guardian_config.include_apps_instructions = false; + guardian_config + .mcp_servers + .set(HashMap::new()) + .map_err(|err| { + anyhow::anyhow!("guardian review session could not clear MCP servers: {err}") + })?; if let Some(live_network_config) = live_network_config && guardian_config.permissions.network.is_some() { @@ -751,6 +758,8 @@ pub(crate) fn build_guardian_review_session_config( Feature::SpawnCsv, Feature::Collab, Feature::CodexHooks, + Feature::Apps, + Feature::Plugins, Feature::WebSearchRequest, Feature::WebSearchCached, ] { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 19007e9d32..098e0f047a 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -16,7 +16,9 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::test_support; use codex_config::config_toml::ConfigToml; +use codex_config::types::McpServerConfig; use codex_exec_server::LOCAL_FS; +use codex_features::Feature; use codex_model_provider::create_model_provider; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::ThreadId; @@ -52,6 +54,7 @@ use insta::Settings; use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -1684,6 +1687,39 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { ); } +#[tokio::test] +async fn guardian_review_session_config_disables_mcp_apps_and_plugins() { + let mut parent_config = test_config().await; + let server: McpServerConfig = + toml::from_str("command = \"docs-server\"").expect("deserialize MCP server"); + parent_config + .mcp_servers + .set(HashMap::from([("docs".to_string(), server)])) + .expect("parent MCP servers are configurable"); + parent_config + .features + .enable(Feature::Apps) + .expect("apps feature is configurable"); + parent_config + .features + .enable(Feature::Plugins) + .expect("plugins feature is configurable"); + parent_config.include_apps_instructions = true; + + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); + + assert!(guardian_config.mcp_servers.get().is_empty()); + assert!(!guardian_config.features.enabled(Feature::Apps)); + assert!(!guardian_config.features.enabled(Feature::Plugins)); + assert!(!guardian_config.include_apps_instructions); +} + #[tokio::test] async fn guardian_review_session_config_rejects_pinned_collab_feature() { let mut parent_config = test_config().await; From 126bd6e7a8839a861ee9bb40ec72c72ea1bf7b4d Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Mon, 20 Apr 2026 14:04:09 -0700 Subject: [PATCH 2/9] Update realtime handoff transcript handling (#18597) ## Summary This PR aims to improve integration between the realtime model and the codex agent by sharing more context with each other. In particular, we now share full realtime conversation transcript deltas in addition to the delegation message. realtime_conversation.rs now turns a handoff into: ``` ... ... ``` ## Implementation notes The transcript is accumulated in the realtime websocket layer as parsed realtime events arrive. When a background-agent handoff is requested, the current transcript snapshot is copied onto the handoff event and then serialized by `realtime_conversation.rs` into the hidden realtime delegation envelope that Codex receives as user-turn context. For Realtime V2, the session now explicitly enables input audio transcription, and the parser handles the relevant input/output transcript completion events so the snapshot includes both user speech and realtime model responses. The delegation `` remains the actual handoff request, while `` carries the surrounding conversation history for context. Reviewers should note that the transcript payload is intended for Codex context sharing, not UI rendering. The realtime delegation envelope should stay hidden from the user-facing transcript surface, while still being included in the background-agent turn so Codex can answer with the same conversational context the realtime model had. --- .../tests/suite/v2/realtime_conversation.rs | 101 ++++++- .../endpoint/realtime_websocket/methods.rs | 276 ++++++++++++++++-- .../endpoint/realtime_websocket/methods_v1.rs | 1 + .../endpoint/realtime_websocket/methods_v2.rs | 8 + .../endpoint/realtime_websocket/protocol.rs | 7 + .../realtime_websocket/protocol_v1.rs | 32 +- .../realtime_websocket/protocol_v2.rs | 41 +-- .../codex-api/tests/realtime_websocket_e2e.rs | 85 +++++- codex-rs/core/src/realtime_conversation.rs | 41 ++- .../core/src/realtime_conversation_tests.rs | 60 +++- .../core/tests/suite/realtime_conversation.rs | 18 +- 11 files changed, 572 insertions(+), 98 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index c61b628a6d..e5676b55ff 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -458,7 +458,10 @@ fn v2_background_agent_tool_call(call_id: &str, prompt: &str) -> Value { async fn realtime_conversation_streams_v2_notifications() -> Result<()> { skip_if_no_network!(Ok(())); - let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let responses_server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("delegated")?, + ]) + .await; let realtime_server = start_websocket_server(vec![vec![ vec![json!({ "type": "session.updated", @@ -489,6 +492,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { "type": "response.output_text.delta", "delta": "working" }), + json!({ + "type": "response.output_text.done", + "text": "working on it" + }), json!({ "type": "conversation.item.done", "item": { @@ -677,7 +684,13 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { handoff_item_added.item["input_transcript"], json!("delegate now") ); - assert_eq!(handoff_item_added.item["active_transcript"], json!([])); + assert_eq!( + handoff_item_added.item["active_transcript"], + json!([ + {"role": "user", "text": "delegate now"}, + {"role": "assistant", "text": "working on it"} + ]) + ); let realtime_error = read_notification::(&mut mcp, "thread/realtime/error") @@ -1142,7 +1155,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { Some("multipart/form-data; boundary=codex-realtime-call-boundary") ); let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; - let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#; + let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"transcription":{"model":"gpt-4o-mini-transcribe"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true,"silence_duration_ms":500}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#; let session = normalized_json_string(session)?; assert_eq!( body, @@ -1241,8 +1254,16 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()> vec![ session_updated("sess_v1_handoff"), json!({ - "type": "conversation.input_transcript.delta", - "delta": "delegate from v1" + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "delegate from v1" + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "the secret word is " + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "kumquat" }), json!({ "type": "conversation.handoff.requested", @@ -1258,6 +1279,12 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()> let started = harness.start_webrtc_realtime("v=offer\r\n").await?; assert_eq!(started.started.version, RealtimeConversationVersion::V1); + assert_call_create_multipart( + harness.call_capture.single_request(), + "v=offer\r\n", + v1_session_create_json(), + )?; + assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?; // Phase 2: wait for the delegated background agent turn that is launched by the handoff request. let turn_started = harness @@ -1274,11 +1301,13 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()> let requests = harness.main_loop_responses_requests().await?; assert_eq!(requests.len(), 1); assert!( - response_request_contains_text(&requests[0], "user: delegate from v1"), - "delegated Responses request should contain realtime text: {}", + response_request_contains_text( + &requests[0], + "\n delegate from v1\n user: delegate from v1\nassistant: the secret word is kumquat\n", + ), + "delegated Responses request should contain realtime delegation envelope: {}", requests[0] ); - let handoff_append = harness.sideband_outbound_request(/*request_index*/ 1).await; assert_eq!( handoff_append, @@ -1526,7 +1555,34 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out realtime_sideband(vec![realtime_sideband_connection(vec![ vec![ session_updated("sess_v2_tool"), - v2_background_agent_tool_call("call_v2", "delegate from v2"), + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Hi how are you" + }), + json!({ + "type": "response.output_audio_transcript.done", + "transcript": "Doing well, what can I help you with?" + }), + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "The secret word is strawberry" + }), + json!({ + "type": "conversation.item.created", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "silent_delegate" + }] + } + }), + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "Got it-strawberry. What's next on the menu?" + }), + v2_background_agent_tool_call("call_v2", "run ls"), ], vec![], vec![], @@ -1553,8 +1609,16 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out let requests = harness.main_loop_responses_requests().await?; assert_eq!(requests.len(), 1); assert!( - response_request_contains_text(&requests[0], "delegate from v2"), - "delegated Responses request should contain tool prompt: {}", + response_request_contains_text( + &requests[0], + "\n run ls\n user: Hi how are you\nassistant: Doing well, what can I help you with?\nuser: The secret word is strawberry\nassistant: Got it-strawberry. What's next on the menu?\nuser: run ls\n", + ), + "delegated Responses request should contain realtime delegation envelope: {}", + requests[0] + ); + assert!( + !response_request_contains_text(&requests[0], ""), + "delegated Responses request should not include realtime control injects: {}", requests[0] ); @@ -2068,7 +2132,16 @@ async fn responses_requests(server: &MockServer) -> Result> { } fn response_request_contains_text(request: &Value, text: &str) -> bool { - request.to_string().contains(text) + match request { + Value::String(value) => value.contains(text), + Value::Array(values) => values + .iter() + .any(|value| response_request_contains_text(value, text)), + Value::Object(map) => map + .values() + .any(|value| response_request_contains_text(value, text)), + Value::Null | Value::Bool(_) | Value::Number(_) => false, + } } fn realtime_tool_ok_command() -> Vec { @@ -2187,6 +2260,10 @@ fn assert_v2_session_update(request: &Value) -> Result<()> { request["session"]["tools"][0]["name"].as_str(), Some("background_agent") ); + assert_eq!( + request["session"]["audio"]["input"]["transcription"]["model"].as_str(), + Some("gpt-4o-mini-transcribe") + ); Ok(()) } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 656b006374..a4ddb5dee4 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -217,6 +217,8 @@ pub struct RealtimeWebsocketEvents { #[derive(Default)] struct ActiveTranscriptState { entries: Vec, + new_input_entry: bool, + new_output_entry: bool, } impl RealtimeWebsocketConnection { @@ -417,38 +419,76 @@ impl RealtimeWebsocketEvents { async fn update_active_transcript(&self, event: &mut RealtimeEvent) { let mut active_transcript = self.active_transcript.lock().await; match event { - RealtimeEvent::InputAudioSpeechStarted(_) => {} + RealtimeEvent::InputAudioSpeechStarted(_) => { + active_transcript.new_input_entry = true; + } RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => { - append_transcript_delta(&mut active_transcript.entries, "user", delta); + let force_new = active_transcript.new_input_entry; + append_transcript_delta(&mut active_transcript.entries, "user", delta, force_new); + active_transcript.new_input_entry = false; } RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => { - append_transcript_delta(&mut active_transcript.entries, "assistant", delta); + let force_new = active_transcript.new_output_entry; + append_transcript_delta( + &mut active_transcript.entries, + "assistant", + delta, + force_new, + ); + active_transcript.new_output_entry = false; + } + RealtimeEvent::InputTranscriptDone(done) => { + let force_new = active_transcript.new_input_entry; + apply_transcript_done( + &mut active_transcript.entries, + "user", + &done.text, + force_new, + ); + active_transcript.new_input_entry = false; + } + RealtimeEvent::OutputTranscriptDone(done) => { + let force_new = active_transcript.new_output_entry; + apply_transcript_done( + &mut active_transcript.entries, + "assistant", + &done.text, + force_new, + ); + active_transcript.new_output_entry = false; } RealtimeEvent::HandoffRequested(handoff) => { - if self.event_parser == RealtimeEventParser::V1 { - handoff.active_transcript = std::mem::take(&mut active_transcript.entries); - } + append_handoff_input(&mut active_transcript.entries, &handoff.input_transcript); + handoff.active_transcript = active_transcript.entries.clone(); + active_transcript.new_input_entry = true; + active_transcript.new_output_entry = true; + } + RealtimeEvent::ResponseCreated(_) => { + active_transcript.new_output_entry = true; } RealtimeEvent::SessionUpdated { .. } - | RealtimeEvent::InputTranscriptDone(_) - | RealtimeEvent::OutputTranscriptDone(_) | RealtimeEvent::AudioOut(_) - | RealtimeEvent::ResponseCreated(_) | RealtimeEvent::ResponseCancelled(_) | RealtimeEvent::ResponseDone(_) - | RealtimeEvent::ConversationItemAdded(_) | RealtimeEvent::ConversationItemDone { .. } + | RealtimeEvent::ConversationItemAdded(_) | RealtimeEvent::Error(_) => {} } } } -fn append_transcript_delta(entries: &mut Vec, role: &str, delta: &str) { +fn append_transcript_delta( + entries: &mut Vec, + role: &str, + delta: &str, + force_new: bool, +) { if delta.is_empty() { return; } - if let Some(last_entry) = entries.last_mut() + if !force_new + && let Some(last_entry) = entries.last_mut() && last_entry.role == role { last_entry.text.push_str(delta); @@ -461,6 +501,48 @@ fn append_transcript_delta(entries: &mut Vec, role: &st }); } +fn apply_transcript_done( + entries: &mut Vec, + role: &str, + text: &str, + force_new: bool, +) { + if text.is_empty() { + return; + } + + if !force_new + && let Some(last_entry) = entries.last_mut() + && last_entry.role == role + { + last_entry.text = text.to_string(); + return; + } + + entries.push(RealtimeTranscriptEntry { + role: role.to_string(), + text: text.to_string(), + }); +} + +fn append_handoff_input(entries: &mut Vec, input: &str) { + let input = input.trim(); + if input.is_empty() || contains_transcript_entry(entries, "user", input) { + return; + } + + entries.push(RealtimeTranscriptEntry { + role: "user".to_string(), + text: input.to_string(), + }); +} + +fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, text: &str) -> bool { + entries + .iter() + .any(|entry| entry.role == role && entry.text.trim() == text.trim()) +} + pub struct RealtimeWebsocketClient { provider: Provider, } @@ -867,6 +949,44 @@ mod tests { ); } + #[test] + fn parse_v1_input_audio_transcription_delta_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.delta", + "item_id": "item_input_1", + "content_index": 0, + "delta": "hello" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hello".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_input_audio_transcription_completed_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "item_input_1", + "content_index": 0, + "transcript": "hello world" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone { + text: "hello world".to_string(), + })) + ); + } + #[test] fn parse_output_transcript_delta_event() { let payload = json!({ @@ -885,6 +1005,66 @@ mod tests { ); } + #[test] + fn parse_v1_output_audio_transcript_delta_event() { + let payload = json!({ + "type": "response.output_audio_transcript.delta", + "delta": "hi" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::OutputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hi".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_output_audio_transcript_done_event() { + let payload = json!({ + "type": "response.output_audio_transcript.done", + "transcript": "hi there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hi there".to_string(), + } + )) + ); + } + + #[test] + fn parse_v1_item_done_output_text_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_output_1", + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "hello"}, + {"type": "output_text", "text": " world"} + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_output_1".to_string(), + }) + ); + } + #[test] fn parse_realtime_v2_handoff_tool_call_event() { let payload = json!({ @@ -930,6 +1110,58 @@ mod tests { ); } + #[test] + fn parse_realtime_v2_output_audio_transcript_done_event() { + let payload = json!({ + "type": "response.output_audio_transcript.done", + "transcript": "hello there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hello there".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_output_text_done_event() { + let payload = json!({ + "type": "response.output_text.done", + "text": "hello there" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::OutputTranscriptDone( + RealtimeTranscriptDone { + text: "hello there".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_conversation_item_created_event() { + let payload = json!({ + "type": "conversation.item.created", + "item": {"type": "message", "role": "user"} + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::ConversationItemAdded( + json!({"type": "message", "role": "user"}) + )) + ); + } + #[test] fn parse_realtime_v2_item_done_output_text_event() { let payload = json!({ @@ -948,11 +1180,9 @@ mod tests { assert_eq!( parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), - Some(RealtimeEvent::OutputTranscriptDone( - RealtimeTranscriptDone { - text: "hello world".to_string(), - } - )) + Some(RealtimeEvent::ConversationItemDone { + item_id: "item_output_1".to_string(), + }) ); } @@ -1582,6 +1812,12 @@ mod tests { "type": "near_field", }) ); + assert_eq!( + first_json["session"]["audio"]["input"]["transcription"], + json!({ + "model": "gpt-4o-mini-transcribe", + }) + ); assert_eq!( first_json["session"]["audio"]["input"]["turn_detection"], json!({ @@ -1756,6 +1992,12 @@ mod tests { Value::String("transcription".to_string()) ); assert!(first_json["session"].get("instructions").is_none()); + assert_eq!( + first_json["session"]["audio"]["input"]["transcription"], + json!({ + "model": "gpt-4o-mini-transcribe", + }) + ); assert!(first_json["session"]["audio"].get("output").is_none()); assert!(first_json["session"].get("tools").is_none()); diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs index 22e728dcff..19e4fa203a 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -55,6 +55,7 @@ pub(super) fn session_update_session( rate: REALTIME_AUDIO_SAMPLE_RATE, }, noise_reduction: None, + transcription: None, turn_detection: None, }, output: Some(SessionAudioOutput { diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs index 32f23a970b..b1f50b02be 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs @@ -18,6 +18,7 @@ use crate::endpoint::realtime_websocket::protocol::SessionAudioInput; use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput; use crate::endpoint::realtime_websocket::protocol::SessionAudioOutputFormat; use crate::endpoint::realtime_websocket::protocol::SessionFunctionTool; +use crate::endpoint::realtime_websocket::protocol::SessionInputAudioTranscription; use crate::endpoint::realtime_websocket::protocol::SessionNoiseReduction; use crate::endpoint::realtime_websocket::protocol::SessionToolType; use crate::endpoint::realtime_websocket::protocol::SessionTurnDetection; @@ -31,6 +32,7 @@ const REALTIME_V2_OUTPUT_MODALITY_TEXT: &str = "text"; const REALTIME_V2_TOOL_CHOICE: &str = "auto"; const REALTIME_V2_BACKGROUND_AGENT_TOOL_NAME: &str = "background_agent"; const REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION: &str = "Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later."; +const REALTIME_V2_INPUT_TRANSCRIPTION_MODEL: &str = "gpt-4o-mini-transcribe"; pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage { RealtimeOutboundMessage::ConversationItemCreate { @@ -80,6 +82,9 @@ pub(super) fn session_update_session( noise_reduction: Some(SessionNoiseReduction { r#type: NoiseReductionType::NearField, }), + transcription: Some(SessionInputAudioTranscription { + model: REALTIME_V2_INPUT_TRANSCRIPTION_MODEL.to_string(), + }), turn_detection: Some(SessionTurnDetection { r#type: TurnDetectionType::ServerVad, interrupt_response: true, @@ -126,6 +131,9 @@ pub(super) fn session_update_session( rate: REALTIME_AUDIO_SAMPLE_RATE, }, noise_reduction: None, + transcription: Some(SessionInputAudioTranscription { + model: REALTIME_V2_INPUT_TRANSCRIPTION_MODEL.to_string(), + }), turn_detection: None, }, output: None, diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 8a88cc34d1..d689f6ea96 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -89,9 +89,16 @@ pub(super) struct SessionAudioInput { #[serde(skip_serializing_if = "Option::is_none")] pub(super) noise_reduction: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub(super) transcription: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(super) turn_detection: Option, } +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionInputAudioTranscription { + pub(super) model: String, +} + #[derive(Debug, Clone, Serialize)] pub(super) struct SessionAudioFormat { #[serde(rename = "type")] diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs index b66cf2b24b..3c1d25aed7 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs @@ -2,6 +2,7 @@ use crate::endpoint::realtime_websocket::protocol_common::parse_error_event; use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload; use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event; use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_done_event; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeHandoffRequested; @@ -38,23 +39,28 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option { item_id: None, })) } - "conversation.input_transcript.delta" => { + "conversation.input_transcript.delta" + | "conversation.item.input_audio_transcription.delta" => { parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) } - "conversation.output_transcript.delta" => { + "conversation.item.input_audio_transcription.completed" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::InputTranscriptDone) + } + "conversation.output_transcript.delta" + | "response.output_text.delta" + | "response.output_audio_transcript.delta" => { parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) } + "response.output_audio_transcript.done" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::OutputTranscriptDone) + } "conversation.item.added" => parsed .get("item") .cloned() .map(RealtimeEvent::ConversationItemAdded), - "conversation.item.done" => parsed - .get("item") - .and_then(Value::as_object) - .and_then(|item| item.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }), + "conversation.item.done" => parse_conversation_item_done_event(&parsed), "conversation.handoff.requested" => { let handoff_id = parsed .get("handoff_id") @@ -82,3 +88,11 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option { } } } + +fn parse_conversation_item_done_event(parsed: &Value) -> Option { + let item = parsed.get("item")?.as_object()?; + item.get("id") + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }) +} diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs index 559e83426b..9aa535757c 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs @@ -10,7 +10,6 @@ use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; use codex_protocol::protocol::RealtimeResponseCancelled; use codex_protocol::protocol::RealtimeResponseCreated; use codex_protocol::protocol::RealtimeResponseDone; -use codex_protocol::protocol::RealtimeTranscriptDone; use serde_json::Map as JsonMap; use serde_json::Value; use tracing::debug; @@ -38,6 +37,13 @@ pub(super) fn parse_realtime_event_v2(payload: &str) -> Option { "response.output_text.delta" | "response.output_audio_transcript.delta" => { parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) } + "response.output_text.done" => { + parse_transcript_done_event(&parsed, "text").map(RealtimeEvent::OutputTranscriptDone) + } + "response.output_audio_transcript.done" => { + parse_transcript_done_event(&parsed, "transcript") + .map(RealtimeEvent::OutputTranscriptDone) + } "input_audio_buffer.speech_started" => Some(RealtimeEvent::InputAudioSpeechStarted( RealtimeInputAudioSpeechStarted { item_id: parsed @@ -46,7 +52,7 @@ pub(super) fn parse_realtime_event_v2(payload: &str) -> Option { .map(str::to_string), }, )), - "conversation.item.added" => parsed + "conversation.item.added" | "conversation.item.created" => parsed .get("item") .cloned() .map(RealtimeEvent::ConversationItemAdded), @@ -122,43 +128,12 @@ fn parse_conversation_item_done_event(parsed: &Value) -> Option { return Some(handoff); } - if let Some(transcript_done) = parse_item_done_transcript(item) { - return Some(transcript_done); - } - item.get("id") .and_then(Value::as_str) .map(str::to_string) .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }) } -fn parse_item_done_transcript(item: &JsonMap) -> Option { - let role = item.get("role").and_then(Value::as_str)?; - let text = item - .get("content") - .and_then(Value::as_array)? - .iter() - .filter_map(item_content_text) - .collect::(); - if text.is_empty() { - return None; - } - - let done = RealtimeTranscriptDone { text }; - match role { - "user" => Some(RealtimeEvent::InputTranscriptDone(done)), - "assistant" => Some(RealtimeEvent::OutputTranscriptDone(done)), - _ => None, - } -} - -fn item_content_text(content: &Value) -> Option<&str> { - content - .get("text") - .or_else(|| content.get("transcript")) - .and_then(Value::as_str) -} - fn parse_handoff_requested_event(item: &JsonMap) -> Option { let item_type = item.get("type").and_then(Value::as_str); let item_name = item.get("name").and_then(Value::as_str); diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index abafaef2ae..2f38d0abcc 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -12,6 +12,9 @@ use codex_api::RealtimeSessionMode; use codex_api::RealtimeWebsocketClient; use codex_api::RetryConfig; use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeTranscriptDelta; +use codex_protocol::protocol::RealtimeTranscriptDone; +use codex_protocol::protocol::RealtimeTranscriptEntry; use codex_protocol::protocol::RealtimeVoice; use futures::SinkExt; use futures::StreamExt; @@ -493,6 +496,46 @@ async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { let first_json: Value = serde_json::from_str(&first).expect("json"); assert_eq!(first_json["type"], "session.update"); + ws.send(Message::Text( + json!({ + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "delegate now" + }) + .to_string() + .into(), + )) + .await + .expect("send input transcript"); + + ws.send(Message::Text( + json!({ + "type": "response.output_audio_transcript.delta", + "delta": "secret context" + }) + .to_string() + .into(), + )) + .await + .expect("send output transcript"); + + ws.send(Message::Text( + json!({ + "type": "conversation.item.created", + "item": { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "silent_delegate" + }] + } + }) + .to_string() + .into(), + )) + .await + .expect("send control item echo"); + ws.send(Message::Text( json!({ "type": "conversation.item.done", @@ -530,6 +573,37 @@ async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { .await .expect("connect"); + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone { + text: "delegate now".to_string() + }) + ); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { + delta: "secret context".to_string() + }) + ); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert!(matches!(event, RealtimeEvent::ConversationItemAdded(_))); + let event = connection .next_event() .await @@ -541,7 +615,16 @@ async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { handoff_id: "call_123".to_string(), item_id: "item_123".to_string(), input_transcript: "delegate now".to_string(), - active_transcript: Vec::new(), + active_transcript: vec![ + RealtimeTranscriptEntry { + role: "user".to_string(), + text: "delegate now".to_string(), + }, + RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "secret context".to_string(), + }, + ], }) ); diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 63b0e1191f..662f7a590f 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -831,16 +831,14 @@ async fn handle_start_inner( } let maybe_routed_text = match &event { RealtimeEvent::HandoffRequested(handoff) => { - realtime_text_from_handoff_request(handoff) + realtime_delegation_from_handoff(handoff) } _ => None, }; if let Some(text) = maybe_routed_text { debug!(text = %text, "[realtime-text] realtime conversation text output"); let sess_for_routed_text = Arc::clone(&sess_clone); - sess_for_routed_text - .route_realtime_text_input(wrap_realtime_delegation_input(&text)) - .await; + sess_for_routed_text.route_realtime_text_input(text).await; } if !fanout_realtime_active.load(Ordering::Relaxed) { break; @@ -890,23 +888,40 @@ pub(crate) async fn handle_audio( } } -fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Option { +fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option { let active_transcript = handoff .active_transcript .iter() .map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text)) .collect::>() .join("\n"); - (!active_transcript.is_empty()) - .then_some(active_transcript) - .or((!handoff.input_transcript.is_empty()).then_some(handoff.input_transcript.clone())) + (!active_transcript.is_empty()).then_some(active_transcript) } -fn wrap_realtime_delegation_input(input: &str) -> String { - format!( - "\n {}\n", - escape_xml_text(input) - ) +fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Option { + (!handoff.input_transcript.is_empty()) + .then_some(handoff.input_transcript.clone()) + .or_else(|| realtime_transcript_delta_from_handoff(handoff)) +} + +fn realtime_delegation_from_handoff(handoff: &RealtimeHandoffRequested) -> Option { + let input = realtime_text_from_handoff_request(handoff)?; + Some(wrap_realtime_delegation_input( + &input, + realtime_transcript_delta_from_handoff(handoff).as_deref(), + )) +} + +fn wrap_realtime_delegation_input(input: &str, transcript_delta: Option<&str>) -> String { + let input = escape_xml_text(input); + if let Some(transcript_delta) = transcript_delta.filter(|text| !text.is_empty()) { + let transcript_delta = escape_xml_text(transcript_delta); + return format!( + "\n {input}\n {transcript_delta}\n" + ); + } + + format!("\n {input}\n") } fn escape_xml_text(input: &str) -> String { diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index cda130e182..a146c43869 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -1,5 +1,6 @@ use super::RealtimeHandoffState; use super::RealtimeSessionKind; +use super::realtime_delegation_from_handoff; use super::realtime_text_from_handoff_request; use super::wrap_realtime_delegation_input; use async_channel::bounded; @@ -8,7 +9,7 @@ use codex_protocol::protocol::RealtimeTranscriptEntry; use pretty_assertions::assert_eq; #[test] -fn extracts_text_from_handoff_request_active_transcript() { +fn prefers_handoff_input_transcript_over_active_transcript() { let handoff = RealtimeHandoffRequested { handoff_id: "handoff_1".to_string(), item_id: "item_1".to_string(), @@ -26,7 +27,50 @@ fn extracts_text_from_handoff_request_active_transcript() { }; assert_eq!( realtime_text_from_handoff_request(&handoff), - Some("user: hello\nassistant: hi there".to_string()) + Some("ignored".to_string()) + ); +} + +#[test] +fn extracts_text_from_handoff_request_active_transcript_if_input_missing() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: String::new(), + active_transcript: vec![RealtimeTranscriptEntry { + role: "user".to_string(), + text: "hello".to_string(), + }], + }; + assert_eq!( + realtime_text_from_handoff_request(&handoff), + Some("user: hello".to_string()) + ); +} + +#[test] +fn wraps_handoff_with_transcript_delta() { + let handoff = RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "delegate this".to_string(), + active_transcript: vec![ + RealtimeTranscriptEntry { + role: "user".to_string(), + text: "hello".to_string(), + }, + RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "hi there".to_string(), + }, + ], + }; + assert_eq!( + realtime_delegation_from_handoff(&handoff), + Some( + "\n delegate this\n user: hello\nassistant: hi there\n" + .to_string() + ) ); } @@ -58,7 +102,7 @@ fn ignores_empty_handoff_request_input_transcript() { #[test] fn wraps_realtime_delegation_input() { assert_eq!( - wrap_realtime_delegation_input("hello"), + wrap_realtime_delegation_input("hello", /*transcript_delta*/ None), "\n hello\n" ); } @@ -66,7 +110,15 @@ fn wraps_realtime_delegation_input() { #[test] fn wraps_realtime_delegation_input_with_xml_escaping() { assert_eq!( - wrap_realtime_delegation_input("use a < b && c > d"), + wrap_realtime_delegation_input("use a < b && c > d", Some("saw ")), + "\n use a < b && c > d\n saw <that>\n" + ); +} + +#[test] +fn wraps_realtime_delegation_input_with_xml_escaping_without_transcript() { + assert_eq!( + wrap_realtime_delegation_input("use a < b && c > d", /*transcript_delta*/ None), "\n use a < b && c > d\n" ); } diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index d92d72b821..0d6e130564 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2480,7 +2480,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { let request = response_mock.single_request(); let user_texts = request.message_input_texts("user"); assert!(user_texts.iter().any(|text| text - == "\n user: text from realtime\n")); + == "\n text from realtime\n user: text from realtime\n")); realtime_server.shutdown().await; Ok(()) @@ -2562,14 +2562,14 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { let request = response_mock.single_request(); let user_texts = request.message_input_texts("user"); assert!(user_texts.iter().any(|text| text - == "\n assistant: assistant context\nuser: delegated query\nassistant: assist confirm\n")); + == "\n ignored\n assistant: assistant context\nuser: delegated query\nassistant: assist confirm\nuser: ignored\n")); realtime_server.shutdown().await; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() -> Result<()> { +async fn inbound_handoff_request_keeps_transcript_history_after_each_handoff() -> Result<()> { skip_if_no_network!(Ok(())); let api_server = start_mock_server().await; @@ -2677,13 +2677,13 @@ async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() - let first_user_texts = requests[0].message_input_texts("user"); assert!(first_user_texts.iter().any(|text| text - == "\n user: first question\n")); + == "\n first question\n user: first question\n")); let second_user_texts = requests[1].message_input_texts("user"); assert!(second_user_texts.iter().any(|text| text - == "\n user: second question\n")); + == "\n second question\n user: first question\nuser: second question\n")); assert!(!second_user_texts.iter().any(|text| text - == "\n user: first question\nuser: second question\n")); + == "\n second question\n user: first questionsecond question\n")); realtime_server.shutdown().await; Ok(()) @@ -3207,11 +3207,11 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { !first_texts .iter() .any(|text| text - == "\n user: steer via realtime\n") + == "\n steer via realtime\n user: steer via realtime\n") ); assert!(second_texts.iter().any(|text| text == "first prompt")); assert!(second_texts.iter().any(|text| text - == "\n user: steer via realtime\n")); + == "\n steer via realtime\n user: steer via realtime\n")); realtime_server.shutdown().await; api_server.shutdown().await; @@ -3327,7 +3327,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); let first_texts = message_input_texts(&first_body, "user"); let expected_text = format!( - "\n user: {delegated_text}\n" + "\n {delegated_text}\n user: {delegated_text}\n" ); assert!(first_texts.iter().any(|text| text == &expected_text)); From baa5dd7b29ac03224a04dfb8b71481ae12b8e4c5 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 20 Apr 2026 14:43:04 -0700 Subject: [PATCH 3/9] Surface TUI skills refresh failures (#18627) ## Why `skills/list` refreshes are best-effort metadata updates. If one fails during startup or thread switching, the TUI should keep running and show enough detail to diagnose the app-server failure instead of leaving the user with only a log entry. This addresses the recoverability and observability issue reported in #16914. ## What Changed - Preserve the full startup `skills/list` error chain before sending it back through the app event queue. - Surface failed skills refreshes as recoverable TUI error messages while still logging the warning. This is related to the recent bug fix from [PR #18370](https://github.com/openai/codex/pull/18370). --- codex-rs/tui/src/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe14c36630..eeba62de00 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2212,7 +2212,7 @@ impl App { tokio::spawn(async move { let result = fetch_skills_list(request_handle, cwd) .await - .map_err(|err| err.to_string()); + .map_err(|err| format!("{err:#}")); app_event_tx.send(AppEvent::SkillsListLoaded { result }); }); } @@ -2795,6 +2795,8 @@ impl App { Ok(response) => self.handle_skills_list_response(response), Err(err) => { tracing::warn!("{failure_message}: {err:#}"); + self.chat_widget + .add_error_message(format!("{failure_message}: {err:#}")); } } } From cc96a03f105ae6af03f1593a167dd7dd8c9d7b47 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 20 Apr 2026 14:52:30 -0700 Subject: [PATCH 4/9] Fix stale model test fixtures (#18719) Fixes stale test fixtures left after the active bundled model catalog updates in #18586 and #18388. Those changes made `gpt-5.4` the current default and removed several older hardcoded slugs, which left Windows Bazel shards failing TUI and config tests. What changed: - Refresh TUI model migration, availability NUX, plan-mode, status, and snapshot fixtures to use active bundled model slugs. - Update the config edit test expectation for the TOML-quoted `"gpt-5.2"` migration key. - Move the model catalog tests into `codex-rs/tui/src/app/tests/model_catalog.rs` so touching them does not trip the blob-size policy for `app.rs`. Verification: - CI Bazel/lint checks are expected to cover the affected test shards. --- codex-rs/core/src/config/edit_tests.rs | 2 +- codex-rs/tui/src/app.rs | 310 +----------------- codex-rs/tui/src/app/tests/model_catalog.rs | 293 +++++++++++++++++ ...gration_prompt_shows_for_hidden_model.snap | 12 + ..._review_denied_renders_denied_request.snap | 2 +- ...w_timed_out_renders_timed_out_request.snap | 2 +- ...artup_failure_renders_warning_history.snap | 2 +- ...i__chatwidget__tests__chatwidget_tall.snap | 2 +- ...compact_queues_user_messages_snapshot.snap | 2 +- ...pproved_exec_renders_approved_request.snap | 2 +- ...ec_renders_warning_and_denied_request.snap | 2 +- ...allel_reviews_render_aggregate_status.snap | 2 +- ...renders_warning_and_timed_out_request.snap | 2 +- ...et__tests__mcp_startup_header_booting.snap | 2 +- ...ests__model_reasoning_selection_popup.snap | 2 +- ...ng_selection_popup_extra_high_warning.snap | 16 +- ...twidget__tests__model_selection_popup.snap | 16 +- ..._tests__preamble_keeps_working_status.snap | 2 +- ...tests__rate_limit_switch_prompt_popup.snap | 6 +- ..._review_queues_user_messages_snapshot.snap | 2 +- ...e_context_label_preserves_status_line.snap | 2 +- ...ide_context_label_shows_parent_status.snap | 2 +- ...rked_side_question_while_task_running.snap | 2 +- ...atwidget__tests__status_widget_active.snap | 2 +- ...ed_exec_begin_restores_working_status.snap | 2 +- ...renders_command_in_single_details_row.snap | 2 +- .../tui/src/chatwidget/tests/plan_mode.rs | 87 +++-- .../chatwidget/tests/popups_and_settings.rs | 34 +- .../src/chatwidget/tests/slash_commands.rs | 4 +- .../chatwidget/tests/status_command_tests.rs | 4 +- ...gration_prompt_shows_for_hidden_model.snap | 11 - 31 files changed, 405 insertions(+), 428 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/model_catalog.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index d2efb21886..ec81c7c06d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -547,7 +547,7 @@ existing = "value" existing = "value" [notice.model_migrations] -gpt-5 = "gpt-5.4" +"gpt-5.2" = "gpt-5.4" "#; assert_eq!(contents, expected); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index eeba62de00..dc8ca2784c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7114,7 +7114,6 @@ mod tests { use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_app_server_protocol::WarningNotification; - use codex_config::types::ModelAvailabilityNuxConfig; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; @@ -7125,7 +7124,6 @@ mod tests { use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; - use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -7152,6 +7150,8 @@ mod tests { use tempfile::tempdir; use tokio::time; + mod model_catalog; + fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } @@ -11474,135 +11474,6 @@ guardian_approval = true .and_then(TomlValue::as_bool) } - fn all_model_presets() -> Vec { - crate::legacy_core::test_support::all_model_presets().clone() - } - - fn model_availability_nux_config(shown_count: &[(&str, u32)]) -> ModelAvailabilityNuxConfig { - ModelAvailabilityNuxConfig { - shown_count: shown_count - .iter() - .map(|(model, count)| ((*model).to_string(), *count)) - .collect(), - } - } - - fn model_migration_copy_to_plain_text( - copy: &crate::model_migration::ModelMigrationCopy, - ) -> String { - if let Some(markdown) = copy.markdown.as_ref() { - return markdown.clone(); - } - let mut s = String::new(); - for span in ©.heading { - s.push_str(&span.content); - } - s.push('\n'); - s.push('\n'); - for line in ©.content { - for span in &line.spans { - s.push_str(&span.content); - } - s.push('\n'); - } - s - } - - #[tokio::test] - async fn model_migration_prompt_only_shows_for_deprecated_models() { - let seen = BTreeMap::new(); - assert!(should_show_model_migration_prompt( - "gpt-5", - "gpt-5.2-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.2-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex-mini", - "gpt-5.2-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.2-codex", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - } - - #[test] - fn select_model_availability_nux_picks_only_eligible_model() { - let mut presets = all_model_presets(); - presets.iter_mut().for_each(|preset| { - preset.availability_nux = None; - }); - let target = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5") - .expect("target preset present"); - target.availability_nux = Some(ModelAvailabilityNux { - message: "gpt-5 is available".to_string(), - }); - - let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); - - assert_eq!( - selected, - Some(StartupTooltipOverride { - model_slug: "gpt-5".to_string(), - message: "gpt-5 is available".to_string(), - }) - ); - } - - #[test] - fn select_model_availability_nux_skips_missing_and_exhausted_models() { - let mut presets = all_model_presets(); - presets.iter_mut().for_each(|preset| { - preset.availability_nux = None; - }); - let gpt_5 = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5") - .expect("gpt-5 preset present"); - gpt_5.availability_nux = Some(ModelAvailabilityNux { - message: "gpt-5 is available".to_string(), - }); - let gpt_5_2 = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5.2") - .expect("gpt-5.2 preset present"); - gpt_5_2.availability_nux = Some(ModelAvailabilityNux { - message: "gpt-5.2 is available".to_string(), - }); - - let selected = select_model_availability_nux( - &presets, - &model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), - ); - - assert_eq!( - selected, - Some(StartupTooltipOverride { - model_slug: "gpt-5.2".to_string(), - message: "gpt-5.2 is available".to_string(), - }) - ); - } - #[test] fn active_turn_not_steerable_turn_error_extracts_structured_server_error() { let turn_error = AppServerTurnError { @@ -11665,183 +11536,6 @@ guardian_approval = true ); } - #[test] - fn select_model_availability_nux_uses_existing_model_order_as_priority() { - let mut presets = all_model_presets(); - presets.iter_mut().for_each(|preset| { - preset.availability_nux = None; - }); - let first = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5") - .expect("gpt-5 preset present"); - first.availability_nux = Some(ModelAvailabilityNux { - message: "first".to_string(), - }); - let second = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5.2") - .expect("gpt-5.2 preset present"); - second.availability_nux = Some(ModelAvailabilityNux { - message: "second".to_string(), - }); - - let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); - - assert_eq!( - selected, - Some(StartupTooltipOverride { - model_slug: "gpt-5.2".to_string(), - message: "second".to_string(), - }) - ); - } - - #[test] - fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() { - let mut presets = all_model_presets(); - presets.iter_mut().for_each(|preset| { - preset.availability_nux = None; - }); - let target = presets - .iter_mut() - .find(|preset| preset.model == "gpt-5") - .expect("target preset present"); - target.availability_nux = Some(ModelAvailabilityNux { - message: "gpt-5 is available".to_string(), - }); - - let selected = select_model_availability_nux( - &presets, - &model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), - ); - - assert_eq!(selected, None); - } - - #[tokio::test] - async fn model_migration_prompt_respects_hide_flag_and_self_target() { - let mut seen = BTreeMap::new(); - seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); - assert!(!should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1", - "gpt-5.1", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn model_migration_prompt_skips_when_target_missing_or_hidden() { - let mut available = all_model_presets(); - let mut current = available - .iter() - .find(|preset| preset.model == "gpt-5-codex") - .cloned() - .expect("preset present"); - current.upgrade = Some(ModelUpgrade { - id: "missing-target".to_string(), - reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), - model_link: None, - upgrade_copy: None, - migration_markdown: None, - }); - available.retain(|preset| preset.model != "gpt-5-codex"); - available.push(current.clone()); - - assert!(!should_show_model_migration_prompt( - ¤t.model, - "missing-target", - &BTreeMap::new(), - &available, - )); - - assert!(target_preset_for_upgrade(&available, "missing-target").is_none()); - - let mut with_hidden_target = all_model_presets(); - let target = with_hidden_target - .iter_mut() - .find(|preset| preset.model == "gpt-5.2-codex") - .expect("target preset present"); - target.show_in_picker = false; - - assert!(!should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.2-codex", - &BTreeMap::new(), - &with_hidden_target, - )); - assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.2-codex").is_none()); - } - - #[tokio::test] - async fn model_migration_prompt_shows_for_hidden_model() { - let codex_home = tempdir().expect("temp codex home"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("config"); - - let mut available_models = all_model_presets(); - let current = available_models - .iter() - .find(|preset| preset.model == "gpt-5.1-codex") - .cloned() - .expect("gpt-5.1-codex preset present"); - assert!( - !current.show_in_picker, - "expected gpt-5.1-codex to be hidden from picker for this test" - ); - - let upgrade = current.upgrade.as_ref().expect("upgrade configured"); - // Test "hidden current model still prompts" even if bundled - // catalog data changes the target model's picker visibility. - available_models - .iter_mut() - .find(|preset| preset.model == upgrade.id) - .expect("upgrade target present") - .show_in_picker = true; - assert!( - should_show_model_migration_prompt( - ¤t.model, - &upgrade.id, - &config.notices.model_migrations, - &available_models, - ), - "expected migration prompt to be eligible for hidden model" - ); - - let target = target_preset_for_upgrade(&available_models, &upgrade.id) - .expect("upgrade target present"); - let target_description = - (!target.description.is_empty()).then(|| target.description.clone()); - let can_opt_out = true; - let copy = migration_copy_for_models( - ¤t.model, - &upgrade.id, - upgrade.model_link.clone(), - upgrade.upgrade_copy.clone(), - upgrade.migration_markdown.clone(), - target.display_name.clone(), - target_description, - can_opt_out, - ); - - // Snapshot the copy we would show; rendering is covered by model_migration snapshots. - assert_snapshot!( - "model_migration_prompt_shows_for_hidden_model", - model_migration_copy_to_plain_text(©) - ); - } - #[tokio::test] async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs new file mode 100644 index 0000000000..e74d01e6db --- /dev/null +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -0,0 +1,293 @@ +use super::*; +use codex_config::types::ModelAvailabilityNuxConfig; +use codex_protocol::openai_models::ModelAvailabilityNux; +use pretty_assertions::assert_eq; + +fn all_model_presets() -> Vec { + crate::legacy_core::test_support::all_model_presets().clone() +} + +fn model_availability_nux_config(shown_count: &[(&str, u32)]) -> ModelAvailabilityNuxConfig { + ModelAvailabilityNuxConfig { + shown_count: shown_count + .iter() + .map(|(model, count)| ((*model).to_string(), *count)) + .collect(), + } +} + +fn model_migration_copy_to_plain_text(copy: &crate::model_migration::ModelMigrationCopy) -> String { + if let Some(markdown) = copy.markdown.as_ref() { + return markdown.clone(); + } + let mut s = String::new(); + for span in ©.heading { + s.push_str(&span.content); + } + s.push('\n'); + s.push('\n'); + for line in ©.content { + for span in &line.spans { + s.push_str(&span.content); + } + s.push('\n'); + } + s +} + +#[tokio::test] +async fn model_migration_prompt_only_shows_for_deprecated_models() { + let seen = BTreeMap::new(); + assert!(should_show_model_migration_prompt( + "gpt-5.2", + "gpt-5.4", + &seen, + &all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5.3-codex", + "gpt-5.4", + &seen, + &all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.3-codex", + "gpt-5.3-codex", + &seen, + &all_model_presets() + )); +} + +#[test] +fn select_model_availability_nux_picks_only_eligible_model() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let target = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4") + .expect("target preset present"); + target.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.4 is available".to_string(), + }); + + let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); + + assert_eq!( + selected, + Some(StartupTooltipOverride { + model_slug: "gpt-5.4".to_string(), + message: "gpt-5.4 is available".to_string(), + }) + ); +} + +#[test] +fn select_model_availability_nux_skips_missing_and_exhausted_models() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let gpt_5 = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4") + .expect("gpt-5.4 preset present"); + gpt_5.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.4 is available".to_string(), + }); + let gpt_5_2 = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4-mini") + .expect("gpt-5.4-mini preset present"); + gpt_5_2.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.4-mini is available".to_string(), + }); + + let selected = select_model_availability_nux( + &presets, + &model_availability_nux_config(&[("gpt-5.4", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), + ); + + assert_eq!( + selected, + Some(StartupTooltipOverride { + model_slug: "gpt-5.4-mini".to_string(), + message: "gpt-5.4-mini is available".to_string(), + }) + ); +} + +#[test] +fn select_model_availability_nux_uses_existing_model_order_as_priority() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let first = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4-mini") + .expect("gpt-5.4-mini preset present"); + first.availability_nux = Some(ModelAvailabilityNux { + message: "first".to_string(), + }); + let second = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4") + .expect("gpt-5.4 preset present"); + second.availability_nux = Some(ModelAvailabilityNux { + message: "second".to_string(), + }); + + let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); + + assert_eq!( + selected, + Some(StartupTooltipOverride { + model_slug: "gpt-5.4".to_string(), + message: "second".to_string(), + }) + ); +} + +#[test] +fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let target = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4") + .expect("target preset present"); + target.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.4 is available".to_string(), + }); + + let selected = select_model_availability_nux( + &presets, + &model_availability_nux_config(&[("gpt-5.4", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), + ); + + assert_eq!(selected, None); +} + +#[tokio::test] +async fn model_migration_prompt_respects_hide_flag_and_self_target() { + let mut seen = BTreeMap::new(); + seen.insert("gpt-5.2".to_string(), "gpt-5.4".to_string()); + assert!(!should_show_model_migration_prompt( + "gpt-5.2", + "gpt-5.4", + &seen, + &all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.4", + "gpt-5.4", + &seen, + &all_model_presets() + )); +} + +#[tokio::test] +async fn model_migration_prompt_skips_when_target_missing_or_hidden() { + let mut available = all_model_presets(); + let mut current = available + .iter() + .find(|preset| preset.model == "gpt-5.2") + .cloned() + .expect("preset present"); + current.upgrade = Some(ModelUpgrade { + id: "missing-target".to_string(), + reasoning_effort_mapping: None, + migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), + model_link: None, + upgrade_copy: None, + migration_markdown: None, + }); + available.retain(|preset| preset.model != "gpt-5.2"); + available.push(current.clone()); + + assert!(!should_show_model_migration_prompt( + ¤t.model, + "missing-target", + &BTreeMap::new(), + &available, + )); + + assert!(target_preset_for_upgrade(&available, "missing-target").is_none()); + + let mut with_hidden_target = all_model_presets(); + let target = with_hidden_target + .iter_mut() + .find(|preset| preset.model == "gpt-5.4") + .expect("target preset present"); + target.show_in_picker = false; + + assert!(!should_show_model_migration_prompt( + "gpt-5.2", + "gpt-5.4", + &BTreeMap::new(), + &with_hidden_target, + )); + assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.4").is_none()); +} + +#[tokio::test] +async fn model_migration_prompt_shows_for_hidden_model() { + let codex_home = tempdir().expect("temp codex home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + + let mut available_models = all_model_presets(); + let current = available_models + .iter_mut() + .find(|preset| preset.model == "gpt-5.3-codex") + .expect("gpt-5.3-codex preset present"); + current.show_in_picker = false; + let current = current.clone(); + assert!( + !current.show_in_picker, + "expected gpt-5.3-codex to be hidden from picker for this test" + ); + + let upgrade = current.upgrade.as_ref().expect("upgrade configured"); + available_models + .iter_mut() + .find(|preset| preset.model == upgrade.id) + .expect("upgrade target present") + .show_in_picker = true; + assert!( + should_show_model_migration_prompt( + ¤t.model, + &upgrade.id, + &config.notices.model_migrations, + &available_models, + ), + "expected migration prompt to be eligible for hidden model" + ); + + let target = + target_preset_for_upgrade(&available_models, &upgrade.id).expect("upgrade target present"); + let target_description = (!target.description.is_empty()).then(|| target.description.clone()); + let can_opt_out = true; + let copy = migration_copy_for_models( + ¤t.model, + &upgrade.id, + upgrade.model_link.clone(), + upgrade.upgrade_copy.clone(), + upgrade.migration_markdown.clone(), + target.display_name.clone(), + target_description, + can_opt_out, + ); + + assert_snapshot!( + "model_migration_prompt_shows_for_hidden_model", + model_migration_copy_to_plain_text(©) + ); +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap new file mode 100644 index 0000000000..c5be01e00e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/tests/model_catalog.rs +assertion_line: 289 +expression: model_migration_copy_to_plain_text(©) +--- +Introducing GPT-5.4 + +Codex just got an upgrade with GPT-5.4, our most capable model for professional work. It outperforms prior models while being more token efficient, with notable improvements on long-running tasks, tool calling, computer use, and frontend development. + +Learn more: https://openai.com/index/introducing-gpt-5-4 + +You can always keep using GPT-5.3-Codex if you prefer. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap index f783bd4d6d..59d02ea1d2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_denied_renders_denied_request.snap @@ -10,4 +10,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap index 2bd33f6359..becb20490b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_guardian_review_timed_out_renders_timed_out_request.snap @@ -17,4 +17,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap index fedd9d6bc2..eb21334162 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__app_server_mcp_startup_failure_renders_warning_history.snap @@ -8,4 +8,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index a287d9d7a2..1ef31b7504 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -26,4 +26,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap index d39932f3a6..5827486bf9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__compact_queues_user_messages_snapshot.snap @@ -9,4 +9,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap index 80592e0dd8..ed1d644cb3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_approved_exec_renders_approved_request.snap @@ -8,4 +8,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap index d910d6d6f3..b8cd76c135 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_denied_exec_renders_warning_and_denied_request.snap @@ -14,4 +14,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap index 821c8d1a6b..393e1a304e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_parallel_reviews_render_aggregate_status.snap @@ -9,4 +9,4 @@ expression: normalize_snapshot_paths(rendered) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap index dc3b546742..9d1eb96bf1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__guardian_timed_out_exec_renders_warning_and_timed_out_request.snap @@ -21,4 +21,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap index f3936540a1..8a5dd2046f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap @@ -8,4 +8,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · /tmp/project " +" gpt-5.4 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index a4a86a41ba..27c7d061ee 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -2,7 +2,7 @@ source: tui/src/chatwidget/tests.rs expression: popup --- - Select Reasoning Level for gpt-5.1-codex-max + Select Reasoning Level for gpt-5.4 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap index 2404dced5d..7cde99f3a9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -1,14 +1,16 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- - Select Reasoning Level for gpt-5.1-codex-max + Select Reasoning Level for gpt-5.2 - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday - tasks - 3. High Greater reasoning depth for complex problems -› 4. Extra high (current) Extra high reasoning depth for complex problems + 1. Low Balances speed with some reasoning; useful for + straightforward queries and short explanations + 2. Medium (default) Provides a solid balance of reasoning depth and + latency for general-purpose tasks + 3. High Maximizes reasoning depth for complex or ambiguous + problems +› 4. Extra high (current) Extra high reasoning for complex problems âš  Extra high reasoning effort can quickly consume Plus plan rate limits. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index d322bf35ed..41773729e0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -1,18 +1,14 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Model and Effort Access legacy models by running codex -m or in your config.toml -› 1. gpt-5.3-codex (default) Latest frontier agentic coding model. - 2. gpt-5.4 Latest frontier agentic coding model. - 3. gpt-5.2-codex Frontier agentic coding model. - 4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 5. gpt-5.2 Latest frontier model with improvements across - knowledge, reasoning and coding - 6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. + 1. gpt-5.4 (default) Latest frontier agentic coding model. + 2. gpt-5.4-mini Smaller frontier agentic coding model. + 3. gpt-5.3-codex Frontier Codex-optimized agentic coding model. +› 4. gpt-5.2 (current) Optimized for professional work and long-running + agents Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap index ad7fa52531..3dbbd8f309 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__preamble_keeps_working_status.snap @@ -8,4 +8,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · /tmp/project " +" gpt-5.4 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index e210d1f0a3..97ed0c7196 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -3,10 +3,10 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Approaching rate limits - Switch to gpt-5.1-codex-mini for lower credit usage? + Switch to gpt-5.4-mini for lower credit usage? -› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, - faster, but less capable. +› 1. Switch to gpt-5.4-mini Smaller frontier agentic coding + model. 2. Keep current model 3. Keep current model (never show again) Hide future rate limit reminders about switching models. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index bb4a8cefe7..5148441eb6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -9,4 +9,4 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap index db30e31a6e..e4d5788602 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.3-codex Side from main thread · Esc to return " +" gpt-5.4 Side from main thread · Esc to return " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap index 0dd45297b6..75e4f53d3a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.3-codex defa… Side from main thread · main needs input · Esc to return " +" gpt-5.4 default · … Side from main thread · main needs input · Esc to return " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_side_requests_forked_side_question_while_task_running.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_side_requests_forked_side_question_while_task_running.snap index 014fb41f1d..4ab1cfb6ea 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_side_requests_forked_side_question_while_task_running.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_side_requests_forked_side_question_while_task_running.snap @@ -8,4 +8,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default Side starting... " +" gpt-5.4 default Side starting... " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index fea8a4db0f..91d80a1a2f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -8,4 +8,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · /tmp/project " +" gpt-5.4 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap index 7a2d0b4b9d..43a3188fbf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap @@ -8,4 +8,4 @@ expression: normalized_backend_snapshot(terminal.backend()) " " "› Ask Codex to do anything " " " -" gpt-5.3-codex default · /tmp/project " +" gpt-5.4 default · /tmp/project " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap index a5b839e00c..0a59dd9909 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap @@ -8,4 +8,4 @@ expression: normalize_snapshot_paths(rendered) › Ask Codex to do anything - gpt-5.3-codex default · /tmp/project + gpt-5.4 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 9f2c81010a..8f3de7897f 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -169,7 +169,7 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { #[tokio::test] async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) @@ -179,7 +179,7 @@ async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() { set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -190,13 +190,13 @@ async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() { AppEvent::OpenPlanReasoningScopePrompt { model, effort: Some(_) - } if model == "gpt-5.1-codex-max" + } if model == "gpt-5.4" ); } #[tokio::test] async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_scope_prompt_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) @@ -205,10 +205,10 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc let _ = drain_insert_history(&mut rx); set_chatgpt_auth(&mut chat); - let current_preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let current_preset = get_available_model(&chat, "gpt-5.4"); chat.set_reasoning_effort(Some(current_preset.default_reasoning_effort)); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -216,7 +216,7 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max" + AppEvent::UpdateModel(model) if model == "gpt-5.4" )), "expected model update event; events: {events:?}" ); @@ -231,7 +231,7 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc #[tokio::test] async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_global_opens_scope_prompt() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) @@ -245,7 +245,7 @@ async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_glo // should open the scope prompt rather than silently rewriting the global default. chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -255,13 +255,13 @@ async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_glo AppEvent::OpenPlanReasoningScopePrompt { model, effort: Some(ReasoningEffortConfig::Medium) - } if model == "gpt-5.1-codex-max" + } if model == "gpt-5.4" ); } #[tokio::test] async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -271,7 +271,7 @@ async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() { .expect("expected plan collaboration mode"); chat.set_collaboration_mask(plan_mask); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 100); @@ -284,7 +284,7 @@ async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() { #[tokio::test] async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_prompt_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) @@ -293,7 +293,7 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp let _ = drain_insert_history(&mut rx); set_chatgpt_auth(&mut chat); - let preset = get_available_model(&chat, "gpt-5"); + let preset = get_available_model(&chat, "gpt-5.2"); chat.open_reasoning_popup(preset); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -301,7 +301,7 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5" + AppEvent::UpdateModel(model) if model == "gpt-5.2" )), "expected model update event; events: {events:?}" ); @@ -315,11 +315,8 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp #[tokio::test] async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.open_plan_reasoning_scope_prompt( - "gpt-5.1-codex-max".to_string(), - Some(ReasoningEffortConfig::High), - ); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High)); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -343,7 +340,7 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override( events.iter().any(|event| matches!( event, AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) } - if model == "gpt-5.1-codex-max" + if model == "gpt-5.4" )), "expected global model reasoning selection persistence; events: {events:?}" ); @@ -369,7 +366,7 @@ fn plan_mode_prompt_notification_uses_dedicated_type_name() { #[tokio::test] async fn open_plan_implementation_prompt_sets_pending_notification() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.config.tui_notifications.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); @@ -383,14 +380,11 @@ async fn open_plan_implementation_prompt_sets_pending_notification() { #[tokio::test] async fn open_plan_reasoning_scope_prompt_sets_pending_notification() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.config.tui_notifications.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); - chat.open_plan_reasoning_scope_prompt( - "gpt-5.1-codex-max".to_string(), - Some(ReasoningEffortConfig::High), - ); + chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High)); assert_matches!( chat.pending_notification, @@ -400,7 +394,7 @@ async fn open_plan_reasoning_scope_prompt_sets_pending_notification() { #[tokio::test] async fn agent_turn_complete_does_not_override_pending_plan_mode_prompt_notification() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.open_plan_implementation_prompt(); chat.notify(Notification::AgentTurnComplete { @@ -415,7 +409,7 @@ async fn agent_turn_complete_does_not_override_pending_plan_mode_prompt_notifica #[tokio::test] async fn request_user_input_notification_overrides_pending_agent_turn_complete_notification() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.notify(Notification::AgentTurnComplete { response: "done".to_string(), @@ -444,7 +438,7 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n #[tokio::test] async fn handle_request_user_input_sets_pending_notification() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.config.tui_notifications.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); @@ -472,10 +466,10 @@ async fn handle_request_user_input_sets_pending_notification() { #[tokio::test] async fn plan_reasoning_scope_popup_mentions_selected_reasoning() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low)); chat.open_plan_reasoning_scope_prompt( - "gpt-5.1-codex-max".to_string(), + "gpt-5.4".to_string(), Some(ReasoningEffortConfig::Medium), ); @@ -489,9 +483,9 @@ async fn plan_reasoning_scope_popup_mentions_selected_reasoning() { #[tokio::test] async fn plan_reasoning_scope_popup_mentions_built_in_plan_default_when_no_override() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.open_plan_reasoning_scope_prompt( - "gpt-5.1-codex-max".to_string(), + "gpt-5.4".to_string(), Some(ReasoningEffortConfig::Medium), ); @@ -501,11 +495,8 @@ async fn plan_reasoning_scope_popup_mentions_built_in_plan_default_when_no_overr #[tokio::test] async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasoning() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.open_plan_reasoning_scope_prompt( - "gpt-5.1-codex-max".to_string(), - Some(ReasoningEffortConfig::High), - ); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1123,7 +1114,7 @@ async fn mode_switch_surfaces_model_change_notification_when_effective_model_cha let mut plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) .expect("expected plan collaboration mode"); - plan_mask.model = Some("gpt-5.1-codex-mini".to_string()); + plan_mask.model = Some("gpt-5.4-mini".to_string()); chat.set_collaboration_mask(plan_mask); let plan_messages = drain_insert_history(&mut rx) @@ -1132,7 +1123,7 @@ async fn mode_switch_surfaces_model_change_notification_when_effective_model_cha .collect::>() .join("\n"); assert!( - plan_messages.contains("Model changed to gpt-5.1-codex-mini medium for Plan mode."), + plan_messages.contains("Model changed to gpt-5.4-mini medium for Plan mode."), "expected Plan-mode model switch notice, got: {plan_messages:?}" ); @@ -1336,21 +1327,21 @@ async fn collaboration_modes_defaults_to_code_on_startup() { #[tokio::test] async fn set_model_updates_active_collaboration_mask() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) .expect("expected plan collaboration mask"); chat.set_collaboration_mask(plan_mask); - chat.set_model("gpt-5.1-codex-mini"); + chat.set_model("gpt-5.4-mini"); - assert_eq!(chat.current_model(), "gpt-5.1-codex-mini"); + assert_eq!(chat.current_model(), "gpt-5.4-mini"); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); } #[tokio::test] async fn set_reasoning_effort_updates_active_collaboration_mask() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) .expect("expected plan collaboration mask"); @@ -1367,7 +1358,7 @@ async fn set_reasoning_effort_updates_active_collaboration_mask() { #[tokio::test] async fn set_reasoning_effort_does_not_override_active_plan_override() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan) @@ -1437,10 +1428,10 @@ async fn collab_mode_applies_default_preset() { #[tokio::test] async fn user_turn_includes_personality_from_config() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.set_feature_enabled(Feature::Personality, /*enabled*/ true); chat.thread_id = Some(ThreadId::new()); - chat.set_model("gpt-5.2-codex"); + chat.set_model("gpt-5.3-codex"); chat.set_personality(Personality::Friendly); chat.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a3792c31f4..e05dcfd5cd 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1901,7 +1901,7 @@ async fn memories_reset_confirmation_sends_event_on_confirm() { #[tokio::test] async fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); @@ -1911,7 +1911,7 @@ async fn model_selection_popup_snapshot() { #[tokio::test] async fn personality_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); chat.open_personality_popup(); @@ -1922,7 +1922,7 @@ async fn personality_selection_popup_snapshot() { #[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_audio_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.open_realtime_audio_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -1932,7 +1932,7 @@ async fn realtime_audio_selection_popup_snapshot() { #[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_audio_selection_popup_narrow_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.open_realtime_audio_popup(); let popup = render_bottom_popup(&chat, /*width*/ 56); @@ -1942,7 +1942,7 @@ async fn realtime_audio_selection_popup_narrow_snapshot() { #[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_microphone_picker_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.config.realtime_audio.microphone = Some("Studio Mic".to_string()); chat.open_realtime_audio_device_selection_with_names( RealtimeAudioDeviceKind::Microphone, @@ -1956,7 +1956,7 @@ async fn realtime_microphone_picker_popup_snapshot() { #[cfg(not(target_os = "linux"))] #[tokio::test] async fn realtime_audio_picker_emits_persist_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.open_realtime_audio_device_selection_with_names( RealtimeAudioDeviceKind::Speaker, vec!["Desk Speakers".to_string(), "Headphones".to_string()], @@ -2017,8 +2017,8 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { #[tokio::test] async fn server_overloaded_error_does_not_switch_models() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; - chat.set_model("gpt-5.2-codex"); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_model("gpt-5.3-codex"); while rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} @@ -2033,7 +2033,7 @@ async fn server_overloaded_error_does_not_switch_models() { while let Ok(event) = rx.try_recv() { if let AppEvent::UpdateModel(model) = event { assert_eq!( - model, "gpt-5.2-codex", + model, "gpt-5.3-codex", "did not expect model switch on server-overloaded error" ); } @@ -2051,12 +2051,12 @@ async fn server_overloaded_error_does_not_switch_models() { #[tokio::test] async fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -2065,12 +2065,12 @@ async fn model_reasoning_selection_popup_snapshot() { #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.2"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -2079,11 +2079,11 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[tokio::test] async fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_chatgpt_auth(&mut chat); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 120); @@ -2193,11 +2193,11 @@ async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_fi #[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; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); + let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let before_escape = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 905ae98377..e91a11eca5 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -299,7 +299,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { } async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { id: "turn-start".into(), @@ -351,7 +351,7 @@ async fn queued_slash_menu_cancel_drains_next_input() { #[tokio::test] async fn queued_slash_menu_selection_drains_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); chat.handle_codex_event(Event { id: "turn-start".into(), diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index a0a32056a5..c206313549 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -78,7 +78,7 @@ async fn status_command_renders_immediately_without_rate_limit_refresh() { #[tokio::test] async fn status_command_uses_catalog_default_reasoning_when_config_empty() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.config.model_reasoning_effort = None; chat.dispatch_command(SlashCommand::Status); @@ -90,7 +90,7 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() { other => panic!("expected status output, got {other:?}"), }; assert!( - rendered.contains("gpt-5.1-codex-max (reasoning medium, summaries auto)"), + rendered.contains("gpt-5.4 (reasoning medium, summaries auto)"), "expected /status to render the catalog default reasoning effort, got: {rendered}" ); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap deleted file mode 100644 index 23fb02d563..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/app.rs -expression: model_migration_copy_to_plain_text(©) ---- -**Codex just got an upgrade. Introducing gpt-5.3-codex.** - -Codex is now powered by gpt-5.3-codex, our most capable agentic coding model yet. It's built for long-running, project-scale work, with mid-turn steering + frequent progress updates so you can collaborate while it runs (and it's faster too). - -Learn more: https://openai.com/index/introducing-gpt-5-3-codex/ - -You can keep using gpt-5.1-codex if you prefer. From 46e5814f77aed16b010b0b5ddae906e57cfef20b Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 20 Apr 2026 15:20:39 -0700 Subject: [PATCH 5/9] Add experimental remote thread store config (#18714) Add experimental config to use remote thread store rather than local thread store implementation in app server --- codex-rs/app-server/src/codex_message_processor.rs | 14 ++++++++++++-- codex-rs/config/src/config_toml.rs | 4 ++++ codex-rs/core/config.schema.json | 6 +++++- codex-rs/core/src/config/config_tests.rs | 4 ++++ codex-rs/core/src/config/mod.rs | 5 +++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 51b1c48c83..501fcad99c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -337,6 +337,7 @@ use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; use codex_thread_store::ListThreadsParams as StoreListThreadsParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::ReadThreadParams as StoreReadThreadParams; +use codex_thread_store::RemoteThreadStore; use codex_thread_store::SortDirection as StoreSortDirection; use codex_thread_store::StoredThread; use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; @@ -474,7 +475,7 @@ pub(crate) struct CodexMessageProcessor { analytics_events_client: AnalyticsEventsClient, arg0_paths: Arg0DispatchPaths, config: Arc, - thread_store: LocalThreadStore, + thread_store: Arc, cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, @@ -642,6 +643,15 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) log_db: Option, } +fn configured_thread_store(config: &Config) -> Arc { + match config.experimental_thread_store_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadStore::new(endpoint)), + None => Arc::new(LocalThreadStore::new( + codex_rollout::RolloutConfig::from_view(config), + )), + } +} + impl CodexMessageProcessor { async fn instruction_sources_from_config(config: &Config) -> Vec { codex_core::AgentsMdManager::new(config) @@ -725,7 +735,7 @@ impl CodexMessageProcessor { outgoing: outgoing.clone(), analytics_events_client, arg0_paths, - thread_store: LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(&config)), + thread_store: configured_thread_store(&config), config, cli_overrides, runtime_feature_enablement, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 6556ca73e3..35e213bc8c 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -302,6 +302,10 @@ pub struct ConfigToml { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, + + /// Experimental / do not use. When set, app-server uses a remote thread + /// store at this endpoint instead of the local filesystem/SQLite store. + pub experimental_thread_store_endpoint: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a2d7b28a86..996e9428e4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2299,6 +2299,10 @@ "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", "type": "string" }, + "experimental_thread_store_endpoint": { + "description": "Experimental / do not use. When set, app-server uses a remote thread store at this endpoint instead of the local filesystem/SQLite store.", + "type": "string" + }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -2957,4 +2961,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 413423518e..7ee5b7df2f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4887,6 +4887,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, + experimental_thread_store_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5038,6 +5039,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, + experimental_thread_store_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5187,6 +5189,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, + experimental_thread_store_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5321,6 +5324,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, + experimental_thread_store_endpoint: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2fef40a427..74421832d8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -527,6 +527,10 @@ pub struct Config { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, + + /// Experimental / do not use. When set, app-server uses a remote thread + /// store at this endpoint instead of the local filesystem/SQLite store. + pub experimental_thread_store_endpoint: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -2273,6 +2277,7 @@ impl Config { experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, + experimental_thread_store_endpoint: cfg.experimental_thread_store_endpoint, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, From cebe57b723e22ffd3f5f46d6c376f90ea3ffb2c5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 20 Apr 2026 19:24:10 -0300 Subject: [PATCH 6/9] fix(tui): keep /copy aligned with rollback (#18739) ## Why Fixes #18718. After rewinding a thread, `/copy` could still copy the latest assistant response from before the rewind. The transcript cells were rolled back, but the copy source was a single `last_agent_markdown` cache that was not synchronized with backtracking, so the visible conversation and copied content could diverge. ## What changed `ChatWidget` now keeps a bounded copy history for the most recent 32 assistant responses, keyed by the visible user-turn count. When local rollback trims transcript cells, the copy cache is trimmed to the same surviving user-turn count so `/copy` uses the latest visible assistant response. If the user rewinds past the retained copy window, `/copy` now reports: ```text Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy. ``` The change also adds coverage for copying the latest surviving response after rollback and for the over-limit rewind message. ## Verification - Manually resumed a synthetic 35-turn session, rewound within the retained window, and verified `/copy` copied the surviving response. - Manually rewound past the retained window and verified `/copy` showed the 32-response limit message. - `cargo test -p codex-tui slash_copy` - `just fix -p codex-tui` - `cargo insta pending-snapshots` Note: `cargo test -p codex-tui` currently fails on unrelated model catalog and snapshot drift around the default model changing to `gpt-5.4`; the focused `/copy` tests pass after fixing the new test setup. --- codex-rs/tui/src/app_backtrack.rs | 4 + codex-rs/tui/src/chatwidget.rs | 76 ++++++++++++++-- codex-rs/tui/src/chatwidget/tests/helpers.rs | 3 + .../src/chatwidget/tests/slash_commands.rs | 86 +++++++++++++++++++ 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2852fbc379..cc99a791df 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -482,6 +482,8 @@ impl App { if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { return false; } + self.chat_widget + .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; true @@ -503,6 +505,8 @@ impl App { &mut self.transcript_cells, pending.selection.nth_user_message, ) { + self.chat_widget + .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); self.sync_overlay_after_transcript_trim(); self.backtrack_render_pending = true; } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dbceecb6cb..c7c3f9c7aa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -473,6 +473,13 @@ fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +const MAX_AGENT_COPY_HISTORY: usize = 32; + +#[derive(Debug)] +struct AgentTurnMarkdown { + user_turn_count: usize, + markdown: String, +} #[derive(Default)] struct RateLimitWarningState { @@ -795,13 +802,17 @@ pub(crate) struct ChatWidget { plan_stream_controller: Option, /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, - /// Raw markdown of the most recently completed agent response. - /// - /// This cache is intentionally best-effort: if the user rolls back the - /// thread and then copies before a replacement response arrives, `/copy` - /// may still return the response from before the rollback. Keeping this as - /// a single cache avoids coupling copy state to the backtrack transcript. + /// Raw markdown of the most recently completed agent response that + /// survived any local thread rollback. last_agent_markdown: Option, + /// Copyable agent responses keyed by the number of visible user turns at + /// the time the response completed. + agent_turn_markdowns: Vec, + /// Number of user turns currently reflected in the visible transcript. + visible_user_turn_count: usize, + /// True when rollback discarded the requested copy source because it was + /// older than the retained copy history. + copy_history_evicted_by_rollback: bool, /// Raw markdown of the most recently completed proposed plan. /// /// This is cached only for the approval popup. It is reset at the start of each new task so the @@ -2045,10 +2056,30 @@ impl ChatWidget { if message.is_empty() { return; } - self.last_agent_markdown = Some(message.to_string()); + let markdown = message.to_string(); + match self.agent_turn_markdowns.last_mut() { + Some(entry) if entry.user_turn_count == self.visible_user_turn_count => { + entry.markdown = markdown.clone(); + } + _ => { + self.agent_turn_markdowns.push(AgentTurnMarkdown { + user_turn_count: self.visible_user_turn_count, + markdown: markdown.clone(), + }); + if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { + self.agent_turn_markdowns.remove(0); + } + } + } + self.last_agent_markdown = Some(markdown); + self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = true; } + fn record_visible_user_turn_for_copy(&mut self) { + self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1); + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_protocol::protocol::SessionConfiguredEvent) { self.on_session_configured_with_display(event, SessionConfiguredDisplay::Normal); @@ -2060,6 +2091,9 @@ impl ChatWidget { display: SessionConfiguredDisplay, ) { self.last_agent_markdown = None; + self.agent_turn_markdowns.clear(); + self.visible_user_turn_count = 0; + self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); @@ -5064,6 +5098,9 @@ impl ChatWidget { agent_turn_running: false, mcp_startup_status: None, last_agent_markdown: None, + agent_turn_markdowns: Vec::new(), + visible_user_turn_count: 0, + copy_history_evicted_by_rollback: false, latest_proposed_plan_markdown: None, saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, @@ -5432,6 +5469,23 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } + pub(crate) fn truncate_agent_copy_history_to_user_turn_count( + &mut self, + user_turn_count: usize, + ) { + self.visible_user_turn_count = user_turn_count; + let had_copy_history = !self.agent_turn_markdowns.is_empty(); + self.agent_turn_markdowns + .retain(|entry| entry.user_turn_count <= user_turn_count); + self.last_agent_markdown = self + .agent_turn_markdowns + .last() + .map(|entry| entry.markdown.clone()); + self.copy_history_evicted_by_rollback = + had_copy_history && self.last_agent_markdown.is_none(); + self.saw_copy_source_this_turn = false; + } + /// Inner implementation with an injectable clipboard backend for testing. fn copy_last_agent_markdown_with( &mut self, @@ -5450,6 +5504,11 @@ impl ChatWidget { "Copy failed: {error}" ))), }, + _ if self.copy_history_evicted_by_rollback => { + self.add_to_history(history_cell::new_error_event(format!( + "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." + ))); + } _ => self.add_to_history(history_cell::new_error_event( "No agent response to copy".into(), )), @@ -5865,6 +5924,7 @@ impl ChatWidget { local_image_paths.clone(), remote_image_urls.clone(), )); + self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( text, text_elements, @@ -5879,6 +5939,7 @@ impl ChatWidget { Vec::new(), remote_image_urls.clone(), )); + self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( String::new(), Vec::new(), @@ -7248,6 +7309,7 @@ impl ChatWidget { || !event.text_elements.is_empty() || !remote_image_urls.is_empty() { + self.record_visible_user_turn_for_copy(); self.add_to_history(history_cell::new_user_prompt( event.message, event.text_elements, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index a0b8882b6d..72ed31a30b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -212,6 +212,9 @@ pub(super) async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, last_agent_markdown: None, + agent_turn_markdowns: Vec::new(), + visible_user_turn_count: 0, + copy_history_evicted_by_rollback: false, latest_proposed_plan_markdown: None, saw_copy_source_this_turn: false, running_commands: HashMap::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e91a11eca5..d255f3e00f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1029,6 +1029,92 @@ async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { ); } +#[tokio::test] +async fn slash_copy_uses_latest_surviving_response_after_rollback() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event_replay(Event { + id: "user-1".into(), + msg: EventMsg::UserMessage(UserMessageEvent { + message: "foo".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + }); + chat.handle_codex_event_replay(Event { + id: "agent-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "foo response".to_string(), + phase: None, + memory_citation: None, + }), + }); + chat.handle_codex_event_replay(Event { + id: "user-2".into(), + msg: EventMsg::UserMessage(UserMessageEvent { + message: "bar".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + }); + chat.handle_codex_event_replay(Event { + id: "agent-2".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "bar response".to_string(), + phase: None, + memory_citation: None, + }), + }); + let _ = drain_insert_history(&mut rx); + assert_eq!(chat.last_agent_markdown_text(), Some("bar response")); + + chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 1); + + assert_eq!(chat.last_agent_markdown_text(), Some("foo response")); + chat.copy_last_agent_markdown_with(|markdown| { + assert_eq!(markdown, "foo response"); + Ok(None) + }); +} + +#[tokio::test] +async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event_replay(Event { + id: "user-1".into(), + msg: EventMsg::UserMessage(UserMessageEvent { + message: "foo".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + }); + chat.handle_codex_event_replay(Event { + id: "agent-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "foo response".to_string(), + phase: None, + memory_citation: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0); + chat.dispatch_command(SlashCommand::Copy); + + let cells = drain_insert_history(&mut rx); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains( + "Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy." + ), + "expected evicted-history message, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 8508629f2347444e648c12b6fbfda96ce3a5b21e Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 15:29:25 -0700 Subject: [PATCH 7/9] [codex-analytics] guardian review analytics events emission --- codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/guardian/analytics.rs | 226 ++++++++++++++ codex-rs/core/src/guardian/mod.rs | 3 + codex-rs/core/src/guardian/review.rs | 309 ++++++++++++++++--- codex-rs/core/src/guardian/review_session.rs | 191 ++++++++++-- codex-rs/core/src/guardian/tests.rs | 26 +- 6 files changed, 683 insertions(+), 74 deletions(-) create mode 100644 codex-rs/core/src/guardian/analytics.rs diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5d67223b33..b30cd18b77 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; @@ -753,6 +754,7 @@ fn spawn_guardian_review( review_id, request, retry_reason, + GuardianApprovalRequestSource::DelegatedSubagent, cancel_token, )); let _ = tx.send(decision); diff --git a/codex-rs/core/src/guardian/analytics.rs b/codex-rs/core/src/guardian/analytics.rs new file mode 100644 index 0000000000..6fe9d141b9 --- /dev/null +++ b/codex-rs/core/src/guardian/analytics.rs @@ -0,0 +1,226 @@ +use std::time::Instant; + +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_analytics::now_unix_seconds; +use codex_features::Feature; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; +use codex_protocol::protocol::TokenUsage; + +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; + +use super::GUARDIAN_REVIEW_TIMEOUT; +use super::GuardianApprovalRequest; +use super::GuardianAssessmentOutcome; + +#[derive(Debug, Clone)] +pub(super) struct GuardianReviewSessionMetadata { + pub(super) guardian_thread_id: String, + pub(super) guardian_session_kind: GuardianReviewSessionKind, + pub(super) guardian_model: String, + pub(super) guardian_reasoning_effort: Option, + pub(super) had_prior_review_context: bool, + pub(super) completed_at: u64, + pub(super) token_usage: Option, +} + +pub(super) struct GuardianReviewAnalyticsContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + started_at: u64, + started_instant: Instant, +} + +pub(super) struct GuardianReviewAnalyticsResult { + pub(super) decision: GuardianReviewDecision, + pub(super) terminal_status: GuardianReviewTerminalStatus, + pub(super) failure_reason: Option, + pub(super) risk_level: Option, + pub(super) user_authorization: Option, + pub(super) outcome: Option, + pub(super) guardian_thread_id: Option, + pub(super) guardian_session_kind: Option, + pub(super) guardian_model: Option, + pub(super) guardian_reasoning_effort: Option, + pub(super) had_prior_review_context: Option, + pub(super) reviewed_action_truncated: bool, + pub(super) token_usage: Option, + pub(super) time_to_first_token_ms: Option, + pub(super) completed_at: u64, +} + +impl GuardianReviewAnalyticsContext { + pub(super) fn new( + session: &Session, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + request: &GuardianApprovalRequest, + ) -> Self { + Self { + thread_id: session.conversation_id.to_string(), + turn_id, + review_id, + target_item_id, + approval_request_source, + reviewed_action: guardian_reviewed_action(request), + started_at: now_unix_seconds(), + started_instant: Instant::now(), + } + } + + pub(super) fn track( + &self, + session: &Session, + turn: &TurnContext, + result: GuardianReviewAnalyticsResult, + ) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + let completion_latency_ms = self.started_instant.elapsed().as_millis() as u64; + session + .services + .analytics_events_client + .track_guardian_review(codex_analytics::GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: result.reviewed_action_truncated, + decision: result.decision, + terminal_status: result.terminal_status, + failure_reason: result.failure_reason, + risk_level: result.risk_level, + user_authorization: result.user_authorization, + outcome: result.outcome, + guardian_thread_id: result.guardian_thread_id, + guardian_session_kind: result.guardian_session_kind, + guardian_model: result.guardian_model, + guardian_reasoning_effort: result.guardian_reasoning_effort, + had_prior_review_context: result.had_prior_review_context, + review_timeout_ms: GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: result.time_to_first_token_ms, + completion_latency_ms: Some(completion_latency_ms), + started_at: self.started_at, + completed_at: Some(result.completed_at), + input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), + cached_input_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: result.token_usage.as_ref().map(|usage| usage.output_tokens), + reasoning_output_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: result.token_usage.as_ref().map(|usage| usage.total_tokens), + }); + } +} + +impl From> for GuardianReviewAnalyticsResult { + fn from(metadata: Option) -> Self { + let completed_at = metadata + .as_ref() + .map_or_else(now_unix_seconds, |metadata| metadata.completed_at); + let mut result = Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + completed_at, + }; + + if let Some(metadata) = metadata { + result.guardian_thread_id = Some(metadata.guardian_thread_id); + result.guardian_session_kind = Some(metadata.guardian_session_kind); + result.guardian_model = Some(metadata.guardian_model); + result.guardian_reasoning_effort = metadata.guardian_reasoning_effort; + result.had_prior_review_context = Some(metadata.had_prior_review_context); + result.token_usage = metadata.token_usage; + } + + result + } +} + +fn guardian_reviewed_action(request: &GuardianApprovalRequest) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + } +} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index f31f21344c..5c8987cc2b 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -11,6 +11,7 @@ //! 3. Fail closed on timeout, execution failure, or malformed output. //! 4. Apply the guardian's explicit allow/deny outcome. +mod analytics; mod approval_request; mod prompt; mod review; @@ -94,6 +95,8 @@ use prompt::render_guardian_transcript_entries; #[cfg(test)] use review::GuardianReviewOutcome; #[cfg(test)] +use review::GuardianReviewSessionResult; +#[cfg(test)] use review::run_guardian_review_session as run_guardian_review_session_for_test; #[cfg(test)] use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index ea52114a09..6884c2aa9b 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,10 @@ use std::sync::Arc; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::now_unix_seconds; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -21,6 +26,9 @@ use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::GuardianAssessmentOutcome; use super::GuardianRejection; +use super::analytics::GuardianReviewAnalyticsContext; +use super::analytics::GuardianReviewAnalyticsResult; +use super::analytics::GuardianReviewSessionMetadata; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; @@ -73,13 +81,79 @@ pub(crate) fn guardian_timeout_message() -> String { GUARDIAN_TIMEOUT_INSTRUCTIONS.to_string() } +#[derive(Debug)] +pub(super) struct GuardianReviewSessionResult { + pub(super) outcome: GuardianReviewOutcome, + pub(super) metadata: Option, +} + #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), + Completed(GuardianAssessment), + Failed(GuardianReviewFailure), TimedOut, Aborted, } +impl GuardianReviewSessionResult { + fn completed( + assessment: GuardianAssessment, + metadata: Option, + ) -> Self { + Self { + outcome: GuardianReviewOutcome::Completed(assessment), + metadata, + } + } + + fn failed( + failure: GuardianReviewFailure, + metadata: Option, + ) -> Self { + Self { + outcome: GuardianReviewOutcome::Failed(failure), + metadata, + } + } + + fn timed_out(metadata: Option) -> Self { + Self { + outcome: GuardianReviewOutcome::TimedOut, + metadata, + } + } + + fn aborted(metadata: Option) -> Self { + Self { + outcome: GuardianReviewOutcome::Aborted, + metadata, + } + } +} + +#[derive(Debug)] +pub(super) enum GuardianReviewFailure { + PromptBuild(anyhow::Error), + Session(anyhow::Error), + Parse(anyhow::Error), +} + +impl GuardianReviewFailure { + fn reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild(_) => GuardianReviewFailureReason::PromptBuildError, + Self::Session(_) => GuardianReviewFailureReason::SessionError, + Self::Parse(_) => GuardianReviewFailureReason::ParseError, + } + } + + fn error(&self) -> &anyhow::Error { + match self { + Self::PromptBuild(err) | Self::Session(err) | Self::Parse(err) => err, + } + } +} + fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { match level { GuardianRiskLevel::Low => "low", @@ -116,11 +190,20 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let analytics_context = GuardianReviewAnalyticsContext::new( + session.as_ref(), + assessment_turn_id.clone(), + review_id.clone(), + target_item_id.clone(), + approval_request_source, + &request, + ); session .send_event( turn.as_ref(), @@ -142,6 +225,17 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + let metadata: Option = None; + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..metadata.into() + }, + ); session .send_event( turn.as_ref(), @@ -163,28 +257,84 @@ async fn run_guardian_review( let schema = guardian_output_schema(); let terminal_action = action_summary.clone(); - let outcome = Box::pin(run_guardian_review_session( + let GuardianReviewSessionResult { outcome, metadata } = Box::pin(run_guardian_review_session( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; + let result = |metadata: Option| { + let completed_at = now_unix_seconds(); + metadata + .map(|mut metadata| { + metadata.completed_at = completed_at; + metadata + }) + .into() + }; let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), - }, + GuardianReviewOutcome::Completed(assessment) => { + let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsResult { + decision: if approved { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if approved { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + ..result(metadata) + }, + ); + assessment + } + GuardianReviewOutcome::Failed(failure) => { + let rationale = format!("Automatic approval review failed: {}", failure.error()); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(failure.reason()), + ..result(metadata) + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } GuardianReviewOutcome::TimedOut => { let rationale = "Automatic approval review timed out while evaluating the requested approval." .to_string(); + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(GuardianReviewFailureReason::Timeout), + ..result(metadata) + }, + ); session .send_event( turn.as_ref(), @@ -212,6 +362,16 @@ async fn run_guardian_review( return ReviewDecision::TimedOut; } GuardianReviewOutcome::Aborted => { + analytics_context.track( + session.as_ref(), + turn.as_ref(), + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..result(metadata) + }, + ); session .send_event( turn.as_ref(), @@ -311,6 +471,7 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await @@ -322,16 +483,18 @@ pub(crate) async fn review_approval_request_with_cancel( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> ReviewDecision { - Box::pin(run_guardian_review( + run_guardian_review( Arc::clone(session), Arc::clone(turn), review_id, request, retry_reason, + approval_request_source, Some(cancel_token), - )) + ) .await } @@ -356,11 +519,16 @@ pub(super) async fn run_guardian_review_session( retry_reason: Option, schema: serde_json::Value, external_cancel: Option, -) -> GuardianReviewOutcome { +) -> GuardianReviewSessionResult { let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewSessionResult::failed( + GuardianReviewFailure::PromptBuild(err), + /*metadata*/ None, + ); + } }, None => None, }; @@ -410,37 +578,94 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return GuardianReviewSessionResult::failed( + GuardianReviewFailure::PromptBuild(err), + /*metadata*/ None, + ); + } }; - match Box::pin( - session - .guardian_review_session - .run_review(GuardianReviewSessionParams { - parent_session: Arc::clone(&session), - parent_turn: turn.clone(), - spawn_config: guardian_config, - request, - retry_reason, - schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - reasoning_summary: turn.reasoning_summary, - personality: turn.personality, - external_cancel, - }), - ) - .await - { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) + let (session_outcome, session_metadata) = Box::pin(session.guardian_review_session.run_review( + GuardianReviewSessionParams { + parent_session: Arc::clone(&session), + parent_turn: turn.clone(), + spawn_config: guardian_config, + request, + retry_reason, + schema, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + reasoning_summary: turn.reasoning_summary, + personality: turn.personality, + external_cancel, + }, + )) + .await; + + match session_outcome { + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => { + GuardianReviewSessionResult::completed(assessment, session_metadata) + } + Err(err) => GuardianReviewSessionResult::failed( + GuardianReviewFailure::Parse(err), + session_metadata, + ), + } + } + None => GuardianReviewSessionResult::failed( + GuardianReviewFailure::Session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + )), + session_metadata, + ), + }, + GuardianReviewSessionOutcome::Completed(Err(err)) => GuardianReviewSessionResult::failed( + GuardianReviewFailure::Session(err), + session_metadata, + ), + GuardianReviewSessionOutcome::PromptBuildFailed(err) => { + GuardianReviewSessionResult::failed( + GuardianReviewFailure::PromptBuild(err), + session_metadata, + ) } - GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) + GuardianReviewSessionOutcome::TimedOut => { + GuardianReviewSessionResult::timed_out(session_metadata) + } + GuardianReviewSessionOutcome::Aborted => { + GuardianReviewSessionResult::aborted(session_metadata) } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + } +} + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_failure_reason_distinguishes_failure_kinds() { + let parse_failure = GuardianReviewFailure::Parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_failure = + GuardianReviewFailure::PromptBuild(anyhow::anyhow!("bad prompt/config")); + let session_failure = + GuardianReviewFailure::Session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_failure.reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_failure.reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_failure.reason(), + GuardianReviewFailureReason::SessionError + )); } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 5bb3e9d1b8..08a3bde1d0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::DeveloperInstructions; @@ -17,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -41,6 +43,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; +use super::analytics::GuardianReviewSessionMetadata; use super::prompt::GuardianPromptMode; use super::prompt::GuardianTranscriptCursor; use super::prompt::build_guardian_prompt_items; @@ -59,6 +62,7 @@ const GUARDIAN_FOLLOWUP_REVIEW_REMINDER: &str = concat!( #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), TimedOut, Aborted, } @@ -102,6 +106,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -265,13 +284,17 @@ impl GuardianReviewSessionManager { } } - pub(crate) async fn run_review( + pub(super) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -305,16 +328,17 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None); } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, None), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -322,9 +346,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + None, + ); }; if trunk.reuse_key != next_reuse_key { @@ -350,20 +377,30 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, metadata) = Box::pin(run_review_on_session( + trunk.as_ref(), + ¶ms, + guardian_session_kind, + deadline, + )) + .await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, Some(metadata)) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -460,7 +497,10 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> ( + GuardianReviewSessionOutcome, + Option, + ) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -479,17 +519,18 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => return (GuardianReviewSessionOutcome::PromptBuildFailed(err), None), + Err(outcome) => return (outcome, None), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = Box::pin(run_review_on_session( + let (outcome, _, metadata) = Box::pin(run_review_on_session( review_session.as_ref(), ¶ms, + GuardianReviewSessionKind::EphemeralForked, deadline, )) .await; @@ -497,7 +538,7 @@ impl GuardianReviewSessionManager { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, Some(metadata)) } } @@ -544,8 +585,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewSessionMetadata, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -560,6 +606,31 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let model_info = params + .parent_session + .services + .models_manager + .get_model_info( + params.model.as_str(), + ¶ms.spawn_config.to_models_manager_config(), + ) + .await; + let guardian_reasoning_effort = if model_info.supports_reasoning_summaries { + params + .reasoning_effort + .or(model_info.default_reasoning_level) + } else { + None + }; + let mut guardian_metadata = GuardianReviewSessionMetadata { + guardian_thread_id: review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + guardian_model: params.model.clone(), + guardian_reasoning_effort: guardian_reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context: had_prior_review_context(&prompt_mode), + completed_at: 0, + token_usage: None, + }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } @@ -584,6 +655,12 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let token_usage_at_review_start = review_session + .codex + .session + .total_token_usage() + .await + .unwrap_or_default(); review_session .codex @@ -603,29 +680,44 @@ async fn run_review_on_session( }) .await?; - Ok::(prompt_items.transcript_cursor) + Ok::<(GuardianTranscriptCursor, TokenUsage), anyhow::Error>(( + prompt_items.transcript_cursor, + token_usage_at_review_start, + )) }), ) .await; let submit_result = match submit_result { Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + Err(outcome) => return (outcome, false, guardian_metadata), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let (transcript_cursor, token_usage_at_review_start) = match submit_result { + Ok(submit_result) => submit_result, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); } }; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + guardian_metadata.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, guardian_metadata) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -654,7 +746,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -663,7 +755,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -673,7 +765,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -685,18 +777,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -704,6 +798,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -1001,4 +1096,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 098e0f047a..283ad28dbd 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -15,6 +15,7 @@ use crate::config_loader::Sourced; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::test_support; +use codex_analytics::GuardianApprovalRequestSource; use codex_config::config_toml::ConfigToml; use codex_config::types::McpServerConfig; use codex_exec_server::LOCAL_FS; @@ -706,6 +707,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { .to_string(), }, /*retry_reason*/ None, + GuardianApprovalRequestSource::MainTurn, cancel_token, ) .await; @@ -921,7 +923,11 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let GuardianReviewSessionResult { + outcome: GuardianReviewOutcome::Completed(assessment), + .. + } = outcome + else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1130,13 +1136,25 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let GuardianReviewSessionResult { + outcome: GuardianReviewOutcome::Completed(first_assessment), + .. + } = first_outcome + else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let GuardianReviewSessionResult { + outcome: GuardianReviewOutcome::Completed(second_assessment), + .. + } = second_outcome + else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let GuardianReviewSessionResult { + outcome: GuardianReviewOutcome::Completed(third_assessment), + .. + } = third_outcome + else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); From 170d525e2363f25866b34270c75a013d53ce0ddc Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 15:29:25 -0700 Subject: [PATCH 8/9] [codex-analytics] guardian review truncation --- codex-rs/core/src/guardian/analytics.rs | 2 + .../core/src/guardian/approval_request.rs | 64 +++++++----- codex-rs/core/src/guardian/mod.rs | 2 +- codex-rs/core/src/guardian/prompt.rs | 20 ++-- codex-rs/core/src/guardian/review_session.rs | 27 +++--- codex-rs/core/src/guardian/tests.rs | 97 +++++++++++++++++-- 6 files changed, 159 insertions(+), 53 deletions(-) diff --git a/codex-rs/core/src/guardian/analytics.rs b/codex-rs/core/src/guardian/analytics.rs index 6fe9d141b9..98a2c71eba 100644 --- a/codex-rs/core/src/guardian/analytics.rs +++ b/codex-rs/core/src/guardian/analytics.rs @@ -27,6 +27,7 @@ pub(super) struct GuardianReviewSessionMetadata { pub(super) guardian_reasoning_effort: Option, pub(super) had_prior_review_context: bool, pub(super) completed_at: u64, + pub(super) reviewed_action_truncated: bool, pub(super) token_usage: Option, } @@ -163,6 +164,7 @@ impl From> for GuardianReviewAnalyticsResu result.guardian_model = Some(metadata.guardian_model); result.guardian_reasoning_effort = metadata.guardian_reasoning_effort; result.had_prior_review_context = Some(metadata.had_prior_review_context); + result.reviewed_action_truncated = metadata.reviewed_action_truncated; result.token_usage = metadata.token_usage; } diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 6d1d3f76af..72ec5fcccd 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -167,32 +167,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -382,10 +399,13 @@ pub(crate) fn guardian_request_turn_id<'a>( } } -pub(crate) fn format_guardian_action_pretty( +pub(crate) fn format_guardian_action_pretty_with_truncation( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 5c8987cc2b..2eaca7da34 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -63,7 +63,7 @@ pub(crate) struct GuardianRejection { } #[cfg(test)] -use approval_request::format_guardian_action_pretty; +use approval_request::format_guardian_action_pretty_with_truncation; #[cfg(test)] use approval_request::guardian_assessment_action; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 687b3e13a7..bc10f6d57d 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -19,7 +19,7 @@ use super::GUARDIAN_RECENT_ENTRY_LIMIT; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::TRUNCATION_TAG; -use super::approval_request::format_guardian_action_pretty; +use super::approval_request::format_guardian_action_pretty_with_truncation; /// Transcript entry retained for guardian review after filtering. #[derive(Debug, PartialEq, Eq)] @@ -56,6 +56,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -91,7 +92,7 @@ pub(crate) async fn build_guardian_prompt_items( parent_history_version: history.history_version(), transcript_entry_count: transcript_entries.len(), }; - let planned_action_json = format_guardian_action_pretty(&request)?; + let planned_action = format_guardian_action_pretty_with_truncation(&request)?; let prompt_shape = match mode { GuardianPromptMode::Full => GuardianPromptShape::Full, @@ -176,11 +177,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action.truncated, }) } @@ -240,7 +242,7 @@ fn render_guardian_transcript_entries_with_offset( } else { GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS }; - let text = guardian_truncate_text(&entry.text, token_cap); + let (text, _) = guardian_truncate_text(&entry.text, token_cap); let rendered = format!( "[{}] {}: {}", index + entry_number_offset + 1, @@ -420,20 +422,20 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -441,7 +443,7 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 08a3bde1d0..1e07e580ca 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -629,6 +629,7 @@ async fn run_review_on_session( guardian_reasoning_effort: guardian_reasoning_effort.map(|effort| effort.to_string()), had_prior_review_context: had_prior_review_context(&prompt_mode), completed_at: 0, + reviewed_action_truncated: false, token_usage: None, }; if send_followup_reminder { @@ -655,6 +656,7 @@ async fn run_review_on_session( prompt_mode, ) .await?; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let token_usage_at_review_start = review_session .codex .session @@ -680,8 +682,9 @@ async fn run_review_on_session( }) .await?; - Ok::<(GuardianTranscriptCursor, TokenUsage), anyhow::Error>(( + Ok::<(GuardianTranscriptCursor, bool, TokenUsage), anyhow::Error>(( prompt_items.transcript_cursor, + reviewed_action_truncated, token_usage_at_review_start, )) }), @@ -691,16 +694,18 @@ async fn run_review_on_session( Ok(submit_result) => submit_result, Err(outcome) => return (outcome, false, guardian_metadata), }; - let (transcript_cursor, token_usage_at_review_start) = match submit_result { - Ok(submit_result) => submit_result, - Err(err) => { - return ( - GuardianReviewSessionOutcome::PromptBuildFailed(err), - false, - guardian_metadata, - ); - } - }; + let (transcript_cursor, reviewed_action_truncated, token_usage_at_review_start) = + match submit_result { + Ok(submit_result) => submit_result, + Err(err) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + false, + guardian_metadata, + ); + } + }; + guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 283ad28dbd..c48f1eaaa9 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -573,11 +573,12 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() { let content = "prefix ".repeat(200) + &" suffix".repeat(200); - let truncated = guardian_truncate_text(&content, /*token_cap*/ 20); + let (truncated, was_truncated) = guardian_truncate_text(&content, /*token_cap*/ 20); assert!(truncated.starts_with("prefix")); assert!(truncated.contains(" serde_json:: patch: patch.clone(), }; - let rendered = format_guardian_action_pretty(&action)?; + let rendered = format_guardian_action_pretty_with_truncation(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: let GuardianReviewSessionResult { outcome: GuardianReviewOutcome::Completed(first_assessment), - .. + metadata: first_metadata, } = first_outcome else { panic!("expected first guardian assessment"); }; + let first_metadata = first_metadata.expect("first guardian session metadata"); let GuardianReviewSessionResult { outcome: GuardianReviewOutcome::Completed(second_assessment), - .. + metadata: second_metadata, } = second_outcome else { panic!("expected second guardian assessment"); }; + let second_metadata = second_metadata.expect("second guardian session metadata"); let GuardianReviewSessionResult { outcome: GuardianReviewOutcome::Completed(third_assessment), - .. + metadata: third_metadata, } = third_outcome else { panic!("expected third guardian assessment"); }; + let third_metadata = third_metadata.expect("third guardian session metadata"); assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkNew + )); + assert!(matches!( + second_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + assert!(matches!( + third_metadata.guardian_session_kind, + codex_analytics::GuardianReviewSessionKind::TrunkReused + )); + ThreadId::from_string(&first_metadata.guardian_thread_id) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string(&second_metadata.guardian_thread_id) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string(&third_metadata.guardian_thread_id) + .expect("third guardian thread id should be a valid UUID"); + assert!(!first_metadata.had_prior_review_context); + assert!(second_metadata.had_prior_review_context); + assert!(third_metadata.had_prior_review_context); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 53e57804e46432592f259b29d71b91e6937ca5fd Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Mon, 20 Apr 2026 15:29:25 -0700 Subject: [PATCH 9/9] [codex-analytics] guardian review TTFT plumbing and emission --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ codex-rs/core/src/guardian/analytics.rs | 2 ++ codex-rs/core/src/guardian/review_session.rs | 14 +++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ .../session/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/session/tests.rs | 10 ++++++++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 18 +++++++++++++---- codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 5 +++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 19 files changed, 116 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 886d9c2557..4a421bec0d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1357,6 +1357,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1431,6 +1432,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1754,6 +1756,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2264,6 +2267,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2301,6 +2305,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2353,6 +2358,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2390,6 +2396,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2564,6 +2571,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2582,6 +2590,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2593,6 +2602,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2627,6 +2637,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2683,6 +2694,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2928,6 +2940,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2987,6 +3000,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3038,6 +3052,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3086,6 +3101,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4701eb3c18..9667dc1b13 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3074,6 +3074,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b444d8c67c..8ab9140b3d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -279,6 +279,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1229,6 +1230,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1315,6 +1317,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/guardian/analytics.rs b/codex-rs/core/src/guardian/analytics.rs index 98a2c71eba..f3cfc27184 100644 --- a/codex-rs/core/src/guardian/analytics.rs +++ b/codex-rs/core/src/guardian/analytics.rs @@ -29,6 +29,7 @@ pub(super) struct GuardianReviewSessionMetadata { pub(super) completed_at: u64, pub(super) reviewed_action_truncated: bool, pub(super) token_usage: Option, + pub(super) time_to_first_token_ms: Option, } pub(super) struct GuardianReviewAnalyticsContext { @@ -165,6 +166,7 @@ impl From> for GuardianReviewAnalyticsResu result.guardian_reasoning_effort = metadata.guardian_reasoning_effort; result.had_prior_review_context = Some(metadata.had_prior_review_context); result.reviewed_action_truncated = metadata.reviewed_action_truncated; + result.time_to_first_token_ms = metadata.time_to_first_token_ms; result.token_usage = metadata.token_usage; } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 1e07e580ca..5b74cdec7a 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -631,6 +631,7 @@ async fn run_review_on_session( completed_at: 0, reviewed_action_truncated: false, token_usage: None, + time_to_first_token_ms: None, }; if send_followup_reminder { append_guardian_followup_reminder(review_session).await; @@ -707,8 +708,13 @@ async fn run_review_on_session( }; guardian_metadata.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut guardian_metadata, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await @@ -751,6 +757,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + metadata: &mut GuardianReviewSessionMetadata, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -776,6 +783,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + metadata.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index c48f1eaaa9..cef5d9e486 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -977,6 +977,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() request_reasoning_effort ); assert!(!metadata.had_prior_review_context); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let mut settings = Settings::clone_current(); settings.set_snapshot_path("snapshots"); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 3dca7f0ce6..a3cb9a7353 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -151,6 +151,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -218,6 +219,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -245,6 +247,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -314,6 +317,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -403,6 +407,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -429,6 +434,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -446,6 +452,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -518,6 +525,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -537,6 +545,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -604,6 +613,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -653,6 +663,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -670,6 +681,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -723,6 +735,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -879,6 +892,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -949,6 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1052,6 +1067,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1160,6 +1176,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1193,6 +1210,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1276,6 +1294,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1427,6 +1446,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bd41da9f20..c5401e1327 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1776,6 +1776,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1961,6 +1962,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1986,6 +1988,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -2063,6 +2066,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2081,6 +2085,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2108,6 +2113,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -2154,6 +2160,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2177,6 +2184,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2200,6 +2208,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -5813,6 +5822,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index e7030d1d94..7874bae406 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -567,11 +567,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 3a525e69eb..d5eaf233b2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1155,6 +1155,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1633,6 +1634,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1661,6 +1663,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8236ae53b7..76e52d6c36 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,13 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + state + .time_to_first_token() + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -102,14 +109,17 @@ fn now_unix_timestamp_secs() -> i64 { } impl TurnTimingStateInner { + fn time_to_first_token(&self) -> Option { + Some(self.first_token_at?.duration_since(self.started_at?)) + } + fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { return None; } - let started_at = self.started_at?; - let first_token_at = Instant::now(); - self.first_token_at = Some(first_token_at); - Some(first_token_at.duration_since(started_at)) + self.started_at?; + self.first_token_at = Some(Instant::now()); + self.time_to_first_token() } fn record_turn_ttfm(&mut self) -> Option { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 8ea2be3147..0368d9baf6 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -70,6 +70,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 475d2941c3..b4fb138bbf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2091,6 +2091,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index c700e5e14f..db5aa1314d 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -785,6 +785,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -816,6 +817,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 9f181d3d21..e343e4a5aa 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1359,6 +1363,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 8f3de7897f..0cea6754f7 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -639,6 +639,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -665,6 +666,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -679,6 +681,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -702,6 +705,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2de9c7eb98..86bd4a26bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -314,6 +314,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -335,6 +336,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d255f3e00f..457a0859e3 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -807,6 +807,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -839,6 +840,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -925,6 +927,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -955,6 +958,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -992,6 +996,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 39b72906b9..bda0729274 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1315,6 +1315,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1665,6 +1666,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2610,6 +2612,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) {