mirror of
https://github.com/openai/codex.git
synced 2026-05-03 02:46:39 +00:00
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:
committed by
GitHub
parent
395729910c
commit
26a7cd21e2
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user