tui: preserve remote image attachments across resume/backtrack (#10590)

## Summary
This PR makes app-server-provided image URLs first-class attachments in
TUI, so they survive resume/backtrack/history recall and are resubmitted
correctly.

<img width="715" height="491" alt="Screenshot 2026-02-12 at 8 27 08 PM"
src="https://github.com/user-attachments/assets/226cbd35-8f0c-4e51-a13e-459ef5dd1927"
/>

Can delete the attached image upon backtracking:
<img width="716" height="301" alt="Screenshot 2026-02-12 at 8 27 31 PM"
src="https://github.com/user-attachments/assets/4558d230-f1bd-4eed-a093-8e1ab9c6db27"
/>

In both history and composer, remote images are rendered as normal
`[Image #N]` placeholders, with numbering unified with local images.

## What changed
- Plumb remote image URLs through TUI message state:
  - `UserHistoryCell`
  - `BacktrackSelection`
  - `ChatComposerHistory::HistoryEntry`
  - `ChatWidget::UserMessage`
- Show remote images as placeholder rows inside the composer box (above
textarea), and in history cells.
- Support keyboard selection/deletion for remote image rows in composer
(`Up`/`Down`, `Delete`/`Backspace`).
- Preserve remote-image-only turns in local composer history (Up/Down
recall), including restore after backtrack.
- Ensure submit/queue/backtrack resubmit include remote images in model
input (`UserInput::Image`), and keep request shape stable for
remote-image-only turns.
- Keep image numbering contiguous across remote + local images:
  - remote images occupy `[Image #1]..[Image #M]`
  - local images start at `[Image #M+1]`
  - deletion renumbers consistently.
- In protocol conversion, increment shared image index for remote images
too, so mixed remote/local image tags stay in a single sequence.
- Simplify restore logic to trust in-memory attachment order (no
placeholder-number parsing path).
- Backtrack/replay rollback handling now queues trims through
`AppEvent::ApplyThreadRollback` and syncs transcript overlay/deferred
lines after trims, so overlay/transcript state stays consistent.
- Trim trailing blank rendered lines from user history rendering to
avoid oversized blank padding.

## Docs + tests
- Updated: `docs/tui-chat-composer.md` (remote image flow,
selection/deletion, numbering offsets)
- Added/updated tests across `tui/src/chatwidget/tests.rs`,
`tui/src/app.rs`, `tui/src/app_backtrack.rs`, `tui/src/history_cell.rs`,
and `tui/src/bottom_pane/chat_composer.rs`
- Added snapshot coverage for remote image composer states, including
deleting the first of two remote images.

## Validation
- `just fmt`
- `cargo test -p codex-tui`

## Codex author
`codex fork 019c2636-1571-74a1-8471-15a3b1c3f49d`
This commit is contained in:
Charley Cunningham
2026-02-13 14:54:06 -08:00
committed by GitHub
parent 395729910c
commit 26a7cd21e2
15 changed files with 1713 additions and 92 deletions

View File

@@ -250,16 +250,174 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
cell.message.clone(),
cell.text_elements.clone(),
cell.local_image_paths.clone(),
cell.remote_image_urls.clone(),
));
break;
}
}
let (stored_message, stored_elements, stored_images) =
let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
user_cell.expect("expected a replayed user history cell");
assert_eq!(stored_message, message);
assert_eq!(stored_elements, text_elements);
assert_eq!(stored_images, local_images);
assert!(stored_remote_image_urls.is_empty());
}
#[tokio::test]
async fn replayed_user_message_preserves_remote_image_urls() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
let message = "replayed with remote image".to_string();
let remote_image_urls = vec!["https://example.com/image.png".to_string()];
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: message.clone(),
images: Some(remote_image_urls.clone()),
text_elements: Vec::new(),
local_images: Vec::new(),
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
user_cell = Some((
cell.message.clone(),
cell.local_image_paths.clone(),
cell.remote_image_urls.clone(),
));
break;
}
}
let (stored_message, stored_local_images, stored_remote_image_urls) =
user_cell.expect("expected a replayed user history cell");
assert_eq!(stored_message, message);
assert!(stored_local_images.is_empty());
assert_eq!(stored_remote_image_urls, remote_image_urls);
}
#[tokio::test]
async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()];
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: String::new(),
images: Some(remote_image_urls.clone()),
text_elements: Vec::new(),
local_images: Vec::new(),
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone()));
break;
}
}
let (stored_message, stored_remote_image_urls) =
user_cell.expect("expected a replayed remote-image-only user history cell");
assert!(stored_message.is_empty());
assert_eq!(stored_remote_image_urls, remote_image_urls);
}
#[tokio::test]
async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
let local_images = vec![PathBuf::from("/tmp/replay-local-only.png")];
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: String::new(),
images: None,
text_elements: Vec::new(),
local_images,
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
let mut found_user_history_cell = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& cell.as_any().downcast_ref::<UserHistoryCell>().is_some()
{
found_user_history_cell = true;
break;
}
}
assert!(!found_user_history_cell);
}
#[tokio::test]
@@ -394,16 +552,288 @@ async fn submission_preserves_text_elements_and_local_images() {
cell.message.clone(),
cell.text_elements.clone(),
cell.local_image_paths.clone(),
cell.remote_image_urls.clone(),
));
break;
}
}
let (stored_message, stored_elements, stored_images) =
let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
user_cell.expect("expected submitted user history cell");
assert_eq!(stored_message, text);
assert_eq!(stored_elements, text_elements);
assert_eq!(stored_images, local_images);
assert!(stored_remote_image_urls.is_empty());
}
#[tokio::test]
async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote.png".to_string();
chat.set_remote_image_urls(vec![remote_url.clone()]);
let placeholder = "[Image #2]";
let text = format!("{placeholder} submit mixed");
let text_elements = vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.to_string()),
)];
let local_images = vec![PathBuf::from("/tmp/submitted-mixed.png")];
chat.bottom_pane
.set_composer_text(text.clone(), text_elements.clone(), local_images.clone());
assert_eq!(chat.bottom_pane.composer_text(), "[Image #2] submit mixed");
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(items.len(), 3);
assert_eq!(
items[0],
UserInput::Image {
image_url: remote_url.clone(),
}
);
assert_eq!(
items[1],
UserInput::LocalImage {
path: local_images[0].clone(),
}
);
assert_eq!(
items[2],
UserInput::Text {
text: text.clone(),
text_elements: text_elements.clone(),
}
);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
user_cell = Some((
cell.message.clone(),
cell.text_elements.clone(),
cell.local_image_paths.clone(),
cell.remote_image_urls.clone(),
));
break;
}
}
let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
user_cell.expect("expected submitted user history cell");
assert_eq!(stored_message, text);
assert_eq!(stored_elements, text_elements);
assert_eq!(stored_images, local_images);
assert_eq!(stored_remote_image_urls, vec![remote_url]);
}
#[tokio::test]
async fn enter_with_only_remote_images_submits_user_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
chat.set_remote_image_urls(vec![remote_url.clone()]);
assert_eq!(chat.bottom_pane.composer_text(), "");
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
items,
vec![UserInput::Image {
image_url: remote_url.clone(),
}]
);
assert!(chat.remote_image_urls().is_empty());
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = ev
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
{
user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone()));
break;
}
}
let (stored_message, stored_remote_image_urls) =
user_cell.expect("expected submitted user history cell");
assert_eq!(stored_message, String::new());
assert_eq!(stored_remote_image_urls, vec![remote_url]);
}
#[tokio::test]
async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
chat.set_remote_image_urls(vec![remote_url.clone()]);
assert_eq!(chat.bottom_pane.composer_text(), "");
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
assert_no_submit_op(&mut op_rx);
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
}
#[tokio::test]
async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
chat.set_remote_image_urls(vec![remote_url.clone()]);
chat.open_review_popup();
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_core::protocol::SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
chat.set_remote_image_urls(vec![remote_url.clone()]);
chat.bottom_pane
.set_composer_input_enabled(false, Some("Input disabled for test.".to_string()));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.remote_image_urls(), vec![remote_url]);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
@@ -508,6 +938,7 @@ async fn blocked_image_restore_preserves_mention_bindings() {
text_elements,
local_images.clone(),
mention_bindings.clone(),
Vec::new(),
);
let mention_start = text.find("$file").expect("mention token exists");
@@ -537,6 +968,94 @@ async fn blocked_image_restore_preserves_mention_bindings() {
);
}
#[tokio::test]
async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mapping() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let first_placeholder = "[Image #2]";
let second_placeholder = "[Image #3]";
let text = format!("{first_placeholder} first\n{second_placeholder} second");
let second_start = text.find(second_placeholder).expect("second placeholder");
let text_elements = vec![
TextElement::new(
(0..first_placeholder.len()).into(),
Some(first_placeholder.to_string()),
),
TextElement::new(
(second_start..second_start + second_placeholder.len()).into(),
Some(second_placeholder.to_string()),
),
];
let local_images = vec![
LocalImageAttachment {
placeholder: first_placeholder.to_string(),
path: PathBuf::from("/tmp/blocked-first.png"),
},
LocalImageAttachment {
placeholder: second_placeholder.to_string(),
path: PathBuf::from("/tmp/blocked-second.png"),
},
];
let remote_image_urls = vec!["https://example.com/blocked-remote.png".to_string()];
chat.restore_blocked_image_submission(
text.clone(),
text_elements.clone(),
local_images.clone(),
Vec::new(),
remote_image_urls.clone(),
);
assert_eq!(chat.bottom_pane.composer_text(), text);
assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements);
assert_eq!(chat.bottom_pane.composer_local_images(), local_images);
assert_eq!(chat.remote_image_urls(), remote_image_urls);
}
#[tokio::test]
async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let first_placeholder = "[Image #2]";
let second_placeholder = "[Image #3]";
let text = format!("{first_placeholder} first\n{second_placeholder} second");
let second_start = text.find(second_placeholder).expect("second placeholder");
let text_elements = vec![
TextElement::new(
(0..first_placeholder.len()).into(),
Some(first_placeholder.to_string()),
),
TextElement::new(
(second_start..second_start + second_placeholder.len()).into(),
Some(second_placeholder.to_string()),
),
];
let local_images = vec![
LocalImageAttachment {
placeholder: first_placeholder.to_string(),
path: PathBuf::from("/tmp/queued-first.png"),
},
LocalImageAttachment {
placeholder: second_placeholder.to_string(),
path: PathBuf::from("/tmp/queued-second.png"),
},
];
let remote_image_urls = vec!["https://example.com/queued-remote.png".to_string()];
chat.restore_user_message_to_composer(UserMessage {
text: text.clone(),
local_images: local_images.clone(),
remote_image_urls: remote_image_urls.clone(),
text_elements: text_elements.clone(),
mention_bindings: Vec::new(),
});
assert_eq!(chat.bottom_pane.composer_text(), text);
assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements);
assert_eq!(chat.bottom_pane.composer_local_images(), local_images);
assert_eq!(chat.remote_image_urls(), remote_image_urls);
}
#[tokio::test]
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -571,6 +1090,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
placeholder: first_placeholder.to_string(),
path: first_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: first_elements,
mention_bindings: Vec::new(),
});
@@ -580,6 +1100,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
placeholder: second_placeholder.to_string(),
path: second_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: second_elements,
mention_bindings: Vec::new(),
});
@@ -649,6 +1170,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
chat.queued_user_messages.push_back(UserMessage {
text: "Implement the plan.".to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
});
@@ -711,6 +1233,7 @@ async fn remap_placeholders_uses_attachment_labels() {
text,
text_elements: elements,
local_images: attachments,
remote_image_urls: vec!["https://example.com/a.png".to_string()],
mention_bindings: Vec::new(),
};
let mut next_label = 3usize;
@@ -743,6 +1266,10 @@ async fn remap_placeholders_uses_attachment_labels() {
},
]
);
assert_eq!(
remapped.remote_image_urls,
vec!["https://example.com/a.png".to_string()]
);
}
#[tokio::test]
@@ -772,6 +1299,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
text,
text_elements: elements,
local_images: attachments,
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
};
let mut next_label = 3usize;
@@ -1150,6 +1678,15 @@ fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
}
}
fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
while let Ok(op) = op_rx.try_recv() {
assert!(
!matches!(op, Op::UserTurn { .. }),
"unexpected submit op: {op:?}"
);
}
}
fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),