mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Merge 53e57804e4 into sapling-pr-archive-rhan-oai
This commit is contained in:
@@ -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<Config>,
|
||||
thread_store: LocalThreadStore,
|
||||
thread_store: Arc<dyn ThreadStore>,
|
||||
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
@@ -642,6 +643,15 @@ pub(crate) struct CodexMessageProcessorArgs {
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
}
|
||||
|
||||
fn configured_thread_store(config: &Config) -> Arc<dyn ThreadStore> {
|
||||
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<AbsolutePathBuf> {
|
||||
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,
|
||||
|
||||
@@ -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::<ThreadRealtimeErrorNotification>(&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],
|
||||
"<realtime_delegation>\n <input>delegate from v1</input>\n <transcript_delta>user: delegate from v1\nassistant: the secret word is kumquat</transcript_delta>\n</realtime_delegation>",
|
||||
),
|
||||
"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": "<realtime_collaboration_update><voice_policy>silent_delegate</voice_policy></realtime_collaboration_update>"
|
||||
}]
|
||||
}
|
||||
}),
|
||||
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],
|
||||
"<realtime_delegation>\n <input>run ls</input>\n <transcript_delta>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</transcript_delta>\n</realtime_delegation>",
|
||||
),
|
||||
"delegated Responses request should contain realtime delegation envelope: {}",
|
||||
requests[0]
|
||||
);
|
||||
assert!(
|
||||
!response_request_contains_text(&requests[0], "<realtime_collaboration_update>"),
|
||||
"delegated Responses request should not include realtime control injects: {}",
|
||||
requests[0]
|
||||
);
|
||||
|
||||
@@ -2068,7 +2132,16 @@ async fn responses_requests(server: &MockServer) -> Result<Vec<Value>> {
|
||||
}
|
||||
|
||||
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<String> {
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ pub struct RealtimeWebsocketEvents {
|
||||
#[derive(Default)]
|
||||
struct ActiveTranscriptState {
|
||||
entries: Vec<RealtimeTranscriptEntry>,
|
||||
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<RealtimeTranscriptEntry>, role: &str, delta: &str) {
|
||||
fn append_transcript_delta(
|
||||
entries: &mut Vec<RealtimeTranscriptEntry>,
|
||||
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<RealtimeTranscriptEntry>, role: &st
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_transcript_done(
|
||||
entries: &mut Vec<RealtimeTranscriptEntry>,
|
||||
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<RealtimeTranscriptEntry>, 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());
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,9 +89,16 @@ pub(super) struct SessionAudioInput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) noise_reduction: Option<SessionNoiseReduction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) transcription: Option<SessionInputAudioTranscription>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) turn_detection: Option<SessionTurnDetection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(super) struct SessionInputAudioTranscription {
|
||||
pub(super) model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(super) struct SessionAudioFormat {
|
||||
#[serde(rename = "type")]
|
||||
|
||||
@@ -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<RealtimeEvent> {
|
||||
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<RealtimeEvent> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_conversation_item_done_event(parsed: &Value) -> Option<RealtimeEvent> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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<RealtimeEvent> {
|
||||
"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<RealtimeEvent> {
|
||||
.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<RealtimeEvent> {
|
||||
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<String, Value>) -> Option<RealtimeEvent> {
|
||||
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::<String>();
|
||||
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<String, Value>) -> Option<RealtimeEvent> {
|
||||
let item_type = item.get("type").and_then(Value::as_str);
|
||||
let item_name = item.get("name").and_then(Value::as_str);
|
||||
|
||||
@@ -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": "<realtime_collaboration_update><voice_policy>silent_delegate</voice_policy></realtime_collaboration_update>"
|
||||
}]
|
||||
}
|
||||
})
|
||||
.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(),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -302,6 +302,10 @@ pub struct ConfigToml {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
/// Controls the web search tool mode: disabled, cached, or live.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -527,6 +527,10 @@ pub struct Config {
|
||||
/// instructions inserted into developer messages when realtime becomes
|
||||
/// active.
|
||||
pub experimental_realtime_start_instructions: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
/// When set, restricts ChatGPT login to a specific workspace identifier.
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -842,6 +842,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()
|
||||
{
|
||||
@@ -861,6 +868,8 @@ pub(crate) fn build_guardian_review_session_config(
|
||||
Feature::SpawnCsv,
|
||||
Feature::Collab,
|
||||
Feature::CodexHooks,
|
||||
Feature::Apps,
|
||||
Feature::Plugins,
|
||||
Feature::WebSearchRequest,
|
||||
Feature::WebSearchCached,
|
||||
] {
|
||||
|
||||
@@ -17,7 +17,9 @@ 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;
|
||||
use codex_features::Feature;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -53,6 +55,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;
|
||||
@@ -1783,6 +1786,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;
|
||||
|
||||
@@ -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<String> {
|
||||
fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option<String> {
|
||||
let active_transcript = handoff
|
||||
.active_transcript
|
||||
.iter()
|
||||
.map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text))
|
||||
.collect::<Vec<_>>()
|
||||
.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!(
|
||||
"<realtime_delegation>\n <input>{}</input>\n</realtime_delegation>",
|
||||
escape_xml_text(input)
|
||||
)
|
||||
fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Option<String> {
|
||||
(!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<String> {
|
||||
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!(
|
||||
"<realtime_delegation>\n <input>{input}</input>\n <transcript_delta>{transcript_delta}</transcript_delta>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
|
||||
format!("<realtime_delegation>\n <input>{input}</input>\n</realtime_delegation>")
|
||||
}
|
||||
|
||||
fn escape_xml_text(input: &str) -> String {
|
||||
|
||||
@@ -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(
|
||||
"<realtime_delegation>\n <input>delegate this</input>\n <transcript_delta>user: hello\nassistant: hi there</transcript_delta>\n</realtime_delegation>"
|
||||
.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),
|
||||
"<realtime_delegation>\n <input>hello</input>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
@@ -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 <that>")),
|
||||
"<realtime_delegation>\n <input>use a < b && c > d</input>\n <transcript_delta>saw <that></transcript_delta>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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),
|
||||
"<realtime_delegation>\n <input>use a < b && c > d</input>\n</realtime_delegation>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
== "<realtime_delegation>\n <input>user: text from realtime</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>text from realtime</input>\n <transcript_delta>user: text from realtime</transcript_delta>\n</realtime_delegation>"));
|
||||
|
||||
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
|
||||
== "<realtime_delegation>\n <input>assistant: assistant context\nuser: delegated query\nassistant: assist confirm</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>ignored</input>\n <transcript_delta>assistant: assistant context\nuser: delegated query\nassistant: assist confirm\nuser: ignored</transcript_delta>\n</realtime_delegation>"));
|
||||
|
||||
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
|
||||
== "<realtime_delegation>\n <input>user: first question</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>first question</input>\n <transcript_delta>user: first question</transcript_delta>\n</realtime_delegation>"));
|
||||
|
||||
let second_user_texts = requests[1].message_input_texts("user");
|
||||
assert!(second_user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: second question</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>second question</input>\n <transcript_delta>user: first question\nuser: second question</transcript_delta>\n</realtime_delegation>"));
|
||||
assert!(!second_user_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: first question\nuser: second question</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>second question</input>\n <transcript_delta>user: first questionsecond question</transcript_delta>\n</realtime_delegation>"));
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
@@ -3207,11 +3207,11 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
|
||||
!first_texts
|
||||
.iter()
|
||||
.any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: steer via realtime</input>\n</realtime_delegation>")
|
||||
== "<realtime_delegation>\n <input>steer via realtime</input>\n <transcript_delta>user: steer via realtime</transcript_delta>\n</realtime_delegation>")
|
||||
);
|
||||
assert!(second_texts.iter().any(|text| text == "first prompt"));
|
||||
assert!(second_texts.iter().any(|text| text
|
||||
== "<realtime_delegation>\n <input>user: steer via realtime</input>\n</realtime_delegation>"));
|
||||
== "<realtime_delegation>\n <input>steer via realtime</input>\n <transcript_delta>user: steer via realtime</transcript_delta>\n</realtime_delegation>"));
|
||||
|
||||
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!(
|
||||
"<realtime_delegation>\n <input>user: {delegated_text}</input>\n</realtime_delegation>"
|
||||
"<realtime_delegation>\n <input>{delegated_text}</input>\n <transcript_delta>user: {delegated_text}</transcript_delta>\n</realtime_delegation>"
|
||||
);
|
||||
assert!(first_texts.iter().any(|text| text == &expected_text));
|
||||
|
||||
|
||||
+5
-309
@@ -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:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7112,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;
|
||||
@@ -7123,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;
|
||||
@@ -7150,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")
|
||||
}
|
||||
@@ -11472,135 +11474,6 @@ guardian_approval = true
|
||||
.and_then(TomlValue::as_bool)
|
||||
}
|
||||
|
||||
fn all_model_presets() -> Vec<ModelPreset> {
|
||||
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 {
|
||||
@@ -11663,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;
|
||||
|
||||
@@ -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<ModelPreset> {
|
||||
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(©)
|
||||
);
|
||||
}
|
||||
+12
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<PlanStreamController>,
|
||||
/// Holds the platform clipboard lease so copied text remains available while supported.
|
||||
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
|
||||
/// 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<String>,
|
||||
/// Copyable agent responses keyed by the number of visible user turns at
|
||||
/// the time the response completed.
|
||||
agent_turn_markdowns: Vec<AgentTurnMarkdown>,
|
||||
/// 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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+9
-7
@@ -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.
|
||||
|
||||
|
||||
+6
-10
@@ -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 <model_name> 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.
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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... "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1127,7 +1118,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)
|
||||
@@ -1136,7 +1127,7 @@ async fn mode_switch_surfaces_model_change_notification_when_effective_model_cha
|
||||
.collect::<Vec<_>>()
|
||||
.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:?}"
|
||||
);
|
||||
|
||||
@@ -1340,21 +1331,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");
|
||||
@@ -1371,7 +1362,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)
|
||||
@@ -1441,10 +1432,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
@@ -1034,6 +1034,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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
-11
@@ -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.
|
||||
Reference in New Issue
Block a user