mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## What changed - Retain question drafts, selections, expanded state, and handled message IDs across thread input restoration and reconnects. Preserve buffered live questions during session refresh without replaying their message text. - Enable history recall and search in question answers, honoring updated key bindings and stripping image attachments and placeholders from recalled entries. Preserve Vim mode when an answer is not accepted. - Let forward navigation past the last question restore the latest queued message as an editable main composer draft. Keep working status and queued messages visible alongside questions. - Show a countdown for newly arrived collapsed questions and clear it when the question editor is opened or used. - Disable question submission and skipping while the thread is unavailable. ## Testing Add regression tests and snapshots for draft restoration and replay deduplication, history search and key bindings, queued-message editing, countdown rendering, and question layout with working status. GitOrigin-RevId: de016120a979db860fd0d4b8098b322b312b7d84
1502 lines
54 KiB
Rust
1502 lines
54 KiB
Rust
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::VecDeque;
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let first_placeholder = "[Image #1]";
|
|
let first_text = format!("{first_placeholder} first");
|
|
let first_elements = vec![TextElement::new(
|
|
(0..first_placeholder.len()).into(),
|
|
Some(first_placeholder.to_string()),
|
|
)];
|
|
let first_images = [PathBuf::from("/tmp/first.png")];
|
|
|
|
let second_placeholder = "[Image #1]";
|
|
let second_text = format!("{second_placeholder} second");
|
|
let second_elements = vec![TextElement::new(
|
|
(0..second_placeholder.len()).into(),
|
|
Some(second_placeholder.to_string()),
|
|
)];
|
|
let second_images = [PathBuf::from("/tmp/second.png")];
|
|
|
|
let existing_placeholder = "[Image #1]";
|
|
let existing_text = format!("{existing_placeholder} existing");
|
|
let existing_elements = vec![TextElement::new(
|
|
(0..existing_placeholder.len()).into(),
|
|
Some(existing_placeholder.to_string()),
|
|
)];
|
|
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
|
|
|
|
chat.input_queue.queued_user_messages.push_back(
|
|
UserMessage {
|
|
text: first_text,
|
|
local_images: vec![LocalImageAttachment {
|
|
placeholder: first_placeholder.to_string(),
|
|
path: first_images[0].clone(),
|
|
}],
|
|
remote_image_urls: Vec::new(),
|
|
text_elements: first_elements,
|
|
mention_bindings: Vec::new(),
|
|
}
|
|
.into(),
|
|
);
|
|
chat.input_queue.queued_user_messages.push_back(
|
|
UserMessage {
|
|
text: second_text,
|
|
local_images: vec![LocalImageAttachment {
|
|
placeholder: second_placeholder.to_string(),
|
|
path: second_images[0].clone(),
|
|
}],
|
|
remote_image_urls: Vec::new(),
|
|
text_elements: second_elements,
|
|
mention_bindings: Vec::new(),
|
|
}
|
|
.into(),
|
|
);
|
|
chat.refresh_pending_input_preview();
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text(existing_text, existing_elements, existing_images.clone());
|
|
|
|
// When interrupted, queued messages are merged into the composer; image placeholders
|
|
// must be renumbered to match the combined local image list.
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
let first = "[Image #1] first".to_string();
|
|
let second = "[Image #2] second".to_string();
|
|
let third = "[Image #3] existing".to_string();
|
|
let expected_text = format!("{first}\n{second}\n{third}");
|
|
assert_eq!(chat.bottom_pane.composer_text(), expected_text);
|
|
|
|
let first_start = 0;
|
|
let second_start = first.len() + 1;
|
|
let third_start = second_start + second.len() + 1;
|
|
let expected_elements = vec![
|
|
TextElement::new(
|
|
(first_start..first_start + "[Image #1]".len()).into(),
|
|
Some("[Image #1]".to_string()),
|
|
),
|
|
TextElement::new(
|
|
(second_start..second_start + "[Image #2]".len()).into(),
|
|
Some("[Image #2]".to_string()),
|
|
),
|
|
TextElement::new(
|
|
(third_start..third_start + "[Image #3]".len()).into(),
|
|
Some("[Image #3]".to_string()),
|
|
),
|
|
];
|
|
assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
|
|
assert_eq!(
|
|
chat.bottom_pane.composer_local_image_paths(),
|
|
vec![
|
|
first_images[0].clone(),
|
|
second_images[0].clone(),
|
|
existing_images[0].clone(),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Entering review mode uses the hint provided by the review request.
|
|
#[tokio::test]
|
|
async fn entered_review_mode_uses_request_hint() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_entered_review_mode(&mut chat, "feature branch");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
|
assert_eq!(banner, ">> Code review started: feature branch <<\n");
|
|
assert!(chat.review.is_review_mode);
|
|
}
|
|
|
|
/// Entering review mode renders the current changes banner when requested.
|
|
#[tokio::test]
|
|
async fn entered_review_mode_defaults_to_current_changes_banner() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_entered_review_mode(&mut chat, "current changes");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
|
assert_eq!(banner, ">> Code review started: current changes <<\n");
|
|
assert!(chat.review.is_review_mode);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_review_prompt_item_is_not_rendered() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_entered_review_mode(&mut chat, "changes against 'main'");
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1);
|
|
assert!(lines_to_single_string(&cells[0]).contains("Code review started"));
|
|
|
|
complete_user_message(
|
|
&mut chat,
|
|
"review-prompt",
|
|
"Review the code changes against the base branch 'main'.",
|
|
);
|
|
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_app_server_review_prompt_item_is_not_rendered() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let review_mode_item = AppServerThreadItem::EnteredReviewMode {
|
|
id: "review-start".to_string(),
|
|
review: "changes against 'main'".to_string(),
|
|
};
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemStarted(ItemStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
started_at_ms: 0,
|
|
item: review_mode_item.clone(),
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1);
|
|
assert!(lines_to_single_string(&cells[0]).contains("Code review started"));
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
completed_at_ms: 0,
|
|
item: review_mode_item,
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
completed_at_ms: 0,
|
|
item: AppServerThreadItem::UserMessage {
|
|
id: "review-prompt".to_string(),
|
|
client_id: None,
|
|
content: vec![AppServerUserInput::Text {
|
|
text: "Review the code changes against the base branch 'main'.".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
handle_entered_review_mode(&mut chat, "feature branch");
|
|
let _ = drain_insert_history(&mut rx);
|
|
chat.input_queue
|
|
.queued_user_messages
|
|
.push_back(UserMessage::from("queued later").into());
|
|
|
|
chat.submit_user_message(UserMessage::from("review follow-up one"));
|
|
chat.submit_user_message(UserMessage::from("review follow-up two"));
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 2);
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "review follow-up one".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected running-turn steer submit, got {other:?}"),
|
|
}
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "review follow-up two".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected second running-turn steer submit, got {other:?}"),
|
|
}
|
|
|
|
handle_error(
|
|
&mut chat,
|
|
"cannot steer a review turn",
|
|
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
|
turn_kind: NonSteerableTurnKind::Review,
|
|
}),
|
|
);
|
|
handle_error(
|
|
&mut chat,
|
|
"cannot steer a review turn",
|
|
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
|
turn_kind: NonSteerableTurnKind::Review,
|
|
}),
|
|
);
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert_eq!(
|
|
chat.queued_user_message_texts(),
|
|
vec![
|
|
"review follow-up one",
|
|
"review follow-up two",
|
|
"queued later"
|
|
]
|
|
);
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
handle_exited_review_mode(&mut chat);
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "review follow-up one\nreview follow-up two".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
|
|
}
|
|
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued later".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued draft submit after rejected steers, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn esc_with_review_queued_steers_shows_warning_and_does_not_interrupt() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
handle_entered_review_mode(&mut chat, "feature branch");
|
|
let _ = drain_insert_history(&mut rx);
|
|
chat.input_queue
|
|
.pending_steers
|
|
.push_back(pending_steer("review follow-up"));
|
|
chat.refresh_pending_input_preview();
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
|
|
assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_no_submit_op(&mut op_rx);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let last = lines_to_single_string(cells.last().expect("review warning"));
|
|
assert_chatwidget_snapshot!("review_submission_warning_snapshot", last);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_agent_message_renders_during_review_mode() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_entered_review_mode(&mut chat, "current changes");
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
complete_assistant_message(
|
|
&mut chat,
|
|
"review-message",
|
|
"Review progress update",
|
|
/*phase*/ None,
|
|
);
|
|
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert_eq!(inserted.len(), 1);
|
|
assert!(lines_to_single_string(&inserted[0]).contains("Review progress update"));
|
|
}
|
|
|
|
/// Exiting review restores the pre-review context window indicator.
|
|
#[tokio::test]
|
|
async fn review_restores_context_window_indicator() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let context_window = 13_000;
|
|
let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline.
|
|
let review_tokens = 12_030; // ~97% remaining after subtracting baseline.
|
|
|
|
handle_token_count(
|
|
&mut chat,
|
|
Some(make_token_info(pre_review_tokens, context_window)),
|
|
);
|
|
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
|
|
|
|
handle_entered_review_mode(&mut chat, "feature branch");
|
|
|
|
handle_token_count(
|
|
&mut chat,
|
|
Some(make_token_info(review_tokens, context_window)),
|
|
);
|
|
assert_eq!(chat.bottom_pane.context_window_percent(), Some(97));
|
|
|
|
handle_exited_review_mode(&mut chat);
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
|
|
assert!(!chat.review.is_review_mode);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn restore_thread_input_state_restores_pending_steers_without_downgrading_them() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let expected_compare_key = PendingSteerCompareKey {
|
|
message: "hidden IDE context\npending steer".to_string(),
|
|
image_count: 0,
|
|
};
|
|
let expected_pending = PendingSteer {
|
|
client_id: "saved-submission".to_string(),
|
|
compare_key: expected_compare_key,
|
|
..pending_steer("pending steer")
|
|
};
|
|
let mut rejected_steers_queue = VecDeque::new();
|
|
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
|
|
let mut queued_user_messages = VecDeque::new();
|
|
queued_user_messages.push_back(UserMessage::from("queued draft").into());
|
|
|
|
chat.restore_thread_input_state(
|
|
Some(ThreadInputState {
|
|
questions: None,
|
|
composer: None,
|
|
safety_buffering_prompt: None,
|
|
pending_steers: VecDeque::from([expected_pending.clone()]),
|
|
rejected_steers_queue,
|
|
rejected_steer_history_records: VecDeque::new(),
|
|
queued_user_messages,
|
|
queued_user_message_history_records: VecDeque::new(),
|
|
recovered_queue: false,
|
|
user_turn_pending_start: false,
|
|
submit_pending_steers_after_interrupt: false,
|
|
current_collaboration_mode: chat.current_collaboration_mode.clone(),
|
|
active_collaboration_mask: chat.active_collaboration_mask.clone(),
|
|
task_running: false,
|
|
agent_turn_running: false,
|
|
}),
|
|
ThreadInputStateRestoreMode {
|
|
preserve_in_flight_turn: true,
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
chat.queued_user_message_texts(),
|
|
vec!["already rejected", "queued draft"]
|
|
);
|
|
assert_eq!(
|
|
chat.input_queue.pending_steers,
|
|
VecDeque::from([expected_pending])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn identical_steer_receipts_only_acknowledge_the_matching_submission() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let pending = pending_steer("continue");
|
|
chat.input_queue.pending_steers.push_back(pending.clone());
|
|
|
|
for (client_id, expected) in [
|
|
("older-submission", VecDeque::from([pending.clone()])),
|
|
(pending.client_id.as_str(), VecDeque::new()),
|
|
] {
|
|
chat.handle_thread_item(
|
|
AppServerThreadItem::UserMessage {
|
|
id: client_id.to_string(),
|
|
client_id: Some(client_id.to_string()),
|
|
content: vec![UserInput::Text {
|
|
text: "continue".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
"turn-1".to_string(),
|
|
ThreadItemRenderSource::Live,
|
|
);
|
|
assert_eq!(chat.input_queue.pending_steers, expected);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steer_enter_queues_while_plan_stream_is_active() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
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.on_task_started();
|
|
chat.on_plan_delta("- Step 1".to_string());
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
|
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue.queued_user_messages.front().unwrap().text,
|
|
"queued submission"
|
|
);
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert_no_submit_op(&mut op_rx);
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("queued while running".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"queued while running"
|
|
);
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { .. } => {}
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
complete_user_message(&mut chat, "user-1", "queued while running");
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert_eq!(inserted.len(), 1);
|
|
assert!(lines_to_single_string(&inserted[0]).contains("queued while running"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
// Keep the assistant stream open (no commit tick/finalize) to model the repro window:
|
|
// user presses Enter while the final answer is still streaming.
|
|
chat.on_agent_message_delta("Final answer line\n".to_string());
|
|
|
|
chat.bottom_pane.set_composer_text(
|
|
"queued while streaming".to_string(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"queued while streaming"
|
|
);
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { .. } => {}
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
complete_user_message(&mut chat, "user-1", "queued while streaming");
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert_eq!(inserted.len(), 1);
|
|
assert!(lines_to_single_string(&inserted[0]).contains("queued while streaming"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_pending_steer_submit_does_not_add_pending_preview() {
|
|
let (mut chat, mut rx, op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
drop(op_rx);
|
|
|
|
chat.bottom_pane.set_composer_text(
|
|
"queued while streaming".to_string(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn item_completed_only_pops_front_pending_steer() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.input_queue
|
|
.pending_steers
|
|
.push_back(pending_steer("first"));
|
|
chat.input_queue
|
|
.pending_steers
|
|
.push_back(pending_steer("second"));
|
|
chat.refresh_pending_input_preview();
|
|
|
|
complete_user_message(&mut chat, "user-other", "other");
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 2);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"first"
|
|
);
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert_eq!(inserted.len(), 1);
|
|
assert!(lines_to_single_string(&inserted[0]).contains("other"));
|
|
|
|
complete_user_message(&mut chat, "user-first", "first");
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"second"
|
|
);
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert_eq!(inserted.len(), 1);
|
|
assert!(lines_to_single_string(&inserted[0]).contains("first"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
let image_path = temp.path().join("pending-steer.png");
|
|
const TINY_PNG_BYTES: &[u8] = &[
|
|
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
|
|
0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
|
1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
|
];
|
|
std::fs::write(&image_path, TINY_PNG_BYTES).expect("write image");
|
|
|
|
let text = "note".to_string();
|
|
let text_elements = vec![TextElement::new((0..4).into(), Some("note".to_string()))];
|
|
chat.submit_user_message(UserMessage {
|
|
text: text.clone(),
|
|
local_images: vec![LocalImageAttachment {
|
|
placeholder: "[Image #1]".to_string(),
|
|
path: image_path,
|
|
}],
|
|
remote_image_urls: Vec::new(),
|
|
text_elements,
|
|
mention_bindings: Vec::new(),
|
|
});
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { .. } => {}
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
let pending = chat.input_queue.pending_steers.front().unwrap();
|
|
assert_eq!(pending.user_message.local_images.len(), 1);
|
|
assert_eq!(pending.user_message.text_elements.len(), 1);
|
|
assert_eq!(pending.compare_key.message, text);
|
|
assert_eq!(pending.compare_key.image_count, 1);
|
|
|
|
complete_user_message_for_inputs(
|
|
&mut chat,
|
|
"user-1",
|
|
vec![
|
|
UserInput::Image {
|
|
url: "data:image/png;base64,placeholder".to_string(),
|
|
detail: None,
|
|
},
|
|
UserInput::Text {
|
|
text,
|
|
text_elements: Vec::new(),
|
|
},
|
|
],
|
|
);
|
|
|
|
assert!(chat.input_queue.pending_steers.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.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 pending steer user history cell");
|
|
assert_eq!(stored_message, "note");
|
|
assert_eq!(
|
|
stored_elements,
|
|
vec![TextElement::new((0..4).into(), Some("note".to_string()))]
|
|
);
|
|
assert_eq!(stored_images.len(), 1);
|
|
assert!(stored_images[0].ends_with("pending-steer.png"));
|
|
assert!(stored_remote_image_urls.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
// Simulate "dead mode" repro timing by keeping a final-answer stream active while the
|
|
// user submits multiple follow-up prompts.
|
|
chat.on_agent_message_delta("Final answer line\n".to_string());
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("first follow-up".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
chat.bottom_pane
|
|
.set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 2);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"first follow-up"
|
|
);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.back()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"second follow-up"
|
|
);
|
|
|
|
let first_items = match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => items,
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
first_items,
|
|
vec![UserInput::Text {
|
|
text: "first follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
);
|
|
let second_items = match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => items,
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
second_items,
|
|
vec![UserInput::Text {
|
|
text: "second follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
);
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
complete_user_message(&mut chat, "user-1", "first follow-up");
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"second follow-up"
|
|
);
|
|
let first_insert = drain_insert_history(&mut rx);
|
|
assert_eq!(first_insert.len(), 1);
|
|
assert!(lines_to_single_string(&first_insert[0]).contains("first follow-up"));
|
|
|
|
complete_user_message(&mut chat, "user-2", "second follow-up");
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
let second_insert = drain_insert_history(&mut rx);
|
|
assert_eq!(second_insert.len(), 1);
|
|
assert!(lines_to_single_string(&second_insert[0]).contains("second follow-up"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manual_interrupt_restores_pending_steers_to_composer() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta(
|
|
"Final answer line
|
|
"
|
|
.to_string(),
|
|
);
|
|
|
|
chat.bottom_pane.set_composer_text(
|
|
"queued while streaming".to_string(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued while streaming".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert_eq!(chat.bottom_pane.composer_text(), "queued while streaming");
|
|
assert_no_submit_op(&mut op_rx);
|
|
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert!(
|
|
inserted
|
|
.iter()
|
|
.all(|cell| !lines_to_single_string(cell).contains("queued while streaming"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_draft() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta("Final answer line\n".to_string());
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("first pending steer".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "first pending steer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("second pending steer".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "second pending steer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
|
|
chat.input_queue
|
|
.queued_user_messages
|
|
.push_back(UserMessage::from("queued draft".to_string()).into());
|
|
chat.refresh_pending_input_preview();
|
|
chat.bottom_pane
|
|
.set_composer_text("still editing".to_string(), Vec::new(), Vec::new());
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
next_interrupt_op(&mut op_rx);
|
|
|
|
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "first pending steer\nsecond pending steer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected merged pending steers to submit, got {other:?}"),
|
|
}
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert_eq!(chat.bottom_pane.composer_text(), "still editing");
|
|
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue.queued_user_messages.front().unwrap().text,
|
|
"queued draft"
|
|
);
|
|
|
|
let inserted = drain_insert_history(&mut rx);
|
|
assert!(
|
|
inserted
|
|
.iter()
|
|
.any(|cell| lines_to_single_string(cell).contains("first pending steer"))
|
|
);
|
|
assert!(
|
|
inserted
|
|
.iter()
|
|
.any(|cell| lines_to_single_string(cell).contains("second pending steer"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn esc_with_pending_steers_overrides_agent_command_interrupt_behavior() {
|
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { .. } => {}
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("/subagents ".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
|
|
next_interrupt_op(&mut op_rx);
|
|
assert_eq!(chat.bottom_pane.composer_text(), "/subagents ");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() {
|
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta("Final answer line\n".to_string());
|
|
|
|
let mention_bindings = vec![MentionBinding {
|
|
sigil: '$',
|
|
mention: "figma".to_string(),
|
|
path: "/tmp/skills/figma/SKILL.md".to_string(),
|
|
}];
|
|
chat.bottom_pane.set_composer_text_with_mention_bindings(
|
|
"please use $figma".to_string(),
|
|
vec![TextElement::new(
|
|
(11..17).into(),
|
|
Some("$figma".to_string()),
|
|
)],
|
|
Vec::new(),
|
|
mention_bindings.clone(),
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "please use $figma".to_string(),
|
|
text_elements: vec![
|
|
TextElement::new((11..17).into(), Some("$figma".to_string())).into()
|
|
],
|
|
}]
|
|
),
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
|
|
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
|
|
|
assert_eq!(chat.bottom_pane.composer_text(), "please use $figma");
|
|
assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings);
|
|
assert_no_submit_op(&mut op_rx);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manual_interrupt_restores_pending_steers_before_queued_messages() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta(
|
|
"Final answer line
|
|
"
|
|
.to_string(),
|
|
);
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
chat.input_queue
|
|
.queued_user_messages
|
|
.push_back(UserMessage::from("queued draft".to_string()).into());
|
|
chat.refresh_pending_input_preview();
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "pending steer".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected Op::UserTurn, got {other:?}"),
|
|
}
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
|
|
|
assert!(chat.input_queue.pending_steers.is_empty());
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(
|
|
chat.bottom_pane.composer_text(),
|
|
"pending steer
|
|
queued draft"
|
|
);
|
|
assert_no_submit_op(&mut op_rx);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ctrl_c_shutdown_works_with_caps_lock() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
|
|
|
|
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.bottom_pane.set_task_running(/*running*/ true);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
|
|
|
next_interrupt_op(&mut op_rx);
|
|
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
|
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
|
|
|
next_interrupt_op(&mut op_rx);
|
|
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
|
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.bottom_pane.insert_str("draft message ");
|
|
chat.bottom_pane
|
|
.attach_image(PathBuf::from("/tmp/preview.png"));
|
|
let placeholder = "[Image #1]";
|
|
assert!(
|
|
chat.bottom_pane.composer_text().ends_with(placeholder),
|
|
"expected placeholder {placeholder:?} in composer text"
|
|
);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
|
assert!(chat.bottom_pane.composer_text().is_empty());
|
|
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
|
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
let restored_text = chat.bottom_pane.composer_text();
|
|
assert!(
|
|
restored_text.ends_with(placeholder),
|
|
"expected placeholder {placeholder:?} after history recall"
|
|
);
|
|
assert!(restored_text.starts_with("draft message "));
|
|
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
|
|
|
let images = chat.bottom_pane.take_recent_submission_images();
|
|
assert_eq!(vec![PathBuf::from("/tmp/preview.png")], images);
|
|
}
|
|
|
|
/// Selecting the custom prompt option from the review popup sends
|
|
/// OpenReviewCustomPrompt to the app event channel.
|
|
#[tokio::test]
|
|
async fn review_popup_custom_prompt_action_sends_event() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Open the preset selection popup
|
|
chat.open_review_popup();
|
|
|
|
// Move selection down to the fourth item: "Custom review instructions"
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
|
// Activate
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
// Drain events and ensure we saw the OpenReviewCustomPrompt request
|
|
let mut found = false;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::OpenReviewCustomPrompt = ev {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found, "expected OpenReviewCustomPrompt event to be sent");
|
|
}
|
|
|
|
/// The commit picker shows only commit subjects (no timestamps).
|
|
#[tokio::test]
|
|
async fn review_commit_picker_shows_subjects_without_timestamps() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Open the Review presets parent popup.
|
|
chat.open_review_popup();
|
|
|
|
// Show commit picker with synthetic entries.
|
|
let entries = vec![
|
|
CommitLogEntry {
|
|
sha: "1111111deadbeef".to_string(),
|
|
timestamp: 0,
|
|
subject: "Add new feature X".to_string(),
|
|
},
|
|
CommitLogEntry {
|
|
sha: "2222222cafebabe".to_string(),
|
|
timestamp: 0,
|
|
subject: "Fix bug Y".to_string(),
|
|
},
|
|
];
|
|
super::show_review_commit_picker_with_entries(&mut chat, entries);
|
|
|
|
// Render the bottom pane and inspect the lines for subjects and absence of time words.
|
|
let width = 72;
|
|
let height = chat.desired_height(width);
|
|
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
|
chat.render(area, &mut buf);
|
|
|
|
let mut blob = String::new();
|
|
for y in 0..area.height {
|
|
for x in 0..area.width {
|
|
let s = buf[(x, y)].symbol();
|
|
if s.is_empty() {
|
|
blob.push(' ');
|
|
} else {
|
|
blob.push_str(s);
|
|
}
|
|
}
|
|
blob.push('\n');
|
|
}
|
|
|
|
assert!(
|
|
blob.contains("Add new feature X"),
|
|
"expected subject in output"
|
|
);
|
|
assert!(blob.contains("Fix bug Y"), "expected subject in output");
|
|
|
|
// Ensure no relative-time phrasing is present.
|
|
let lowered = blob.to_lowercase();
|
|
assert!(
|
|
!lowered.contains("ago")
|
|
&& !lowered.contains(" second")
|
|
&& !lowered.contains(" minute")
|
|
&& !lowered.contains(" hour")
|
|
&& !lowered.contains(" day"),
|
|
"expected no relative time in commit picker output: {blob:?}"
|
|
);
|
|
}
|
|
|
|
/// Submitting the custom prompt view sends Op::Review with the typed prompt
|
|
/// and uses the same text for the user-facing hint.
|
|
#[tokio::test]
|
|
async fn custom_prompt_submit_sends_review_op() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.show_review_custom_prompt();
|
|
// Paste prompt text via ChatWidget handler, then submit
|
|
chat.handle_paste(" please audit dependencies ".to_string());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
|
|
let evt = rx.try_recv().expect("expected one app event");
|
|
match evt {
|
|
AppEvent::CodexOp(Op::Review { target }) => {
|
|
assert_eq!(
|
|
target,
|
|
ReviewTarget::Custom {
|
|
instructions: "please audit dependencies".to_string(),
|
|
}
|
|
);
|
|
}
|
|
other => panic!("unexpected app event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Hitting Enter on an empty custom prompt view does not submit.
|
|
#[tokio::test]
|
|
async fn custom_prompt_enter_empty_does_not_send() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.show_review_custom_prompt();
|
|
// Enter without any text
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
// No AppEvent::CodexOp should be sent
|
|
assert!(rx.try_recv().is_err(), "no app event should be sent");
|
|
}
|
|
|
|
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
|
|
// marker (replacing the spinner) and flushes it into history.
|
|
#[tokio::test]
|
|
async fn interrupt_exec_marks_failed_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Begin a long-running command so we have an active exec cell with a spinner.
|
|
begin_exec(&mut chat, "call-int", "sleep 1");
|
|
|
|
// Simulate the task being aborted (as if ESC was pressed), which should
|
|
// cause the active exec cell to be finalized as failed and flushed.
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
!cells.is_empty(),
|
|
"expected finalized exec cell to be inserted into history"
|
|
);
|
|
|
|
// The first inserted cell should be the finalized exec; snapshot its text.
|
|
let exec_blob = lines_to_single_string(&cells[0]);
|
|
assert_chatwidget_snapshot!("interrupt_exec_marks_failed", exec_blob);
|
|
}
|
|
|
|
// Snapshot test: after an interrupted turn, a gentle error message is inserted
|
|
// suggesting the user to tell the model what to do differently and to use /feedback.
|
|
#[tokio::test]
|
|
async fn interrupted_turn_error_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Simulate an in-progress task so the widget is in a running state.
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
// Abort the turn (like pressing Esc) and drain inserted history.
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
!cells.is_empty(),
|
|
"expected error message to be inserted after interruption"
|
|
);
|
|
let last = lines_to_single_string(cells.last().unwrap());
|
|
assert_chatwidget_snapshot!("interrupted_turn_error_message", last);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
|
|
|
chat.handle_server_notification(
|
|
codex_app_server_protocol::ServerNotification::TurnStarted(
|
|
codex_app_server_protocol::TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: codex_app_server_protocol::Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: codex_app_server_protocol::TurnStatus::InProgress,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
),
|
|
/*replay_kind*/ None,
|
|
);
|
|
chat.handle_server_notification(
|
|
codex_app_server_protocol::ServerNotification::ThreadGoalUpdated(
|
|
codex_app_server_protocol::ThreadGoalUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: Some("turn-1".to_string()),
|
|
goal: codex_app_server_protocol::ThreadGoal {
|
|
thread_id: "thread-1".to_string(),
|
|
objective: "Run until the token budget is limited".to_string(),
|
|
status: codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
|
|
token_budget: Some(10_000),
|
|
tokens_used: 10_500,
|
|
time_used_seconds: 0,
|
|
created_at: 0,
|
|
updated_at: 1,
|
|
},
|
|
},
|
|
),
|
|
/*replay_kind*/ None,
|
|
);
|
|
chat.handle_server_notification(
|
|
codex_app_server_protocol::ServerNotification::TurnCompleted(
|
|
codex_app_server_protocol::TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: codex_app_server_protocol::Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: codex_app_server_protocol::TurnStatus::Interrupted,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let last = lines_to_single_string(cells.last().unwrap());
|
|
assert_chatwidget_snapshot!("interrupted_turn_goal_budget_limited_message", last);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
handle_budget_limited_turn(&mut chat, "turn-1");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let last = lines_to_single_string(cells.last().unwrap());
|
|
assert_chatwidget_snapshot!("direct_budget_limited_turn_message", last);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn budget_limited_turn_restores_queued_input_without_submitting() {
|
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.input_queue
|
|
.queued_user_messages
|
|
.push_back(UserMessage::from("follow-up after budget stop").into());
|
|
chat.refresh_pending_input_preview();
|
|
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
handle_budget_limited_turn(&mut chat, "turn-1");
|
|
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(
|
|
chat.bottom_pane.composer_text(),
|
|
"follow-up after budget stop"
|
|
);
|
|
assert_no_submit_op(&mut op_rx);
|
|
}
|
|
|
|
// Snapshot test: interrupting specifically to submit pending steers shows an
|
|
// informational banner instead of the generic "tell the model what to do
|
|
// differently" error prompt.
|
|
#[tokio::test]
|
|
async fn interrupted_turn_pending_steers_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
chat.input_queue
|
|
.pending_steers
|
|
.push_back(pending_steer("steer 1"));
|
|
chat.input_queue.submit_pending_steers_after_interrupt = true;
|
|
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let info = cells
|
|
.iter()
|
|
.map(|cell| lines_to_single_string(cell))
|
|
.find(|line| line.contains("Model interrupted to submit steer instructions."))
|
|
.expect("expected steer interrupt info message to be inserted");
|
|
assert_chatwidget_snapshot!("interrupted_turn_pending_steers_message", info);
|
|
}
|
|
|
|
/// Opening custom prompt from the review popup, pressing Esc returns to the
|
|
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
|
#[tokio::test]
|
|
async fn review_custom_prompt_escape_navigates_back_then_dismisses() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Open the Review presets parent popup.
|
|
chat.open_review_popup();
|
|
|
|
// Open the custom prompt submenu (child view) directly.
|
|
chat.show_review_custom_prompt();
|
|
|
|
// Verify child view is on top.
|
|
let header = render_bottom_first_row(&chat, /*width*/ 60);
|
|
assert!(
|
|
header.contains("Custom review instructions"),
|
|
"expected custom prompt view header: {header:?}"
|
|
);
|
|
|
|
// Esc once: child view closes, parent (review presets) remains.
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
let header = render_bottom_first_row(&chat, /*width*/ 60);
|
|
assert!(
|
|
header.contains("Select a review preset"),
|
|
"expected to return to parent review popup: {header:?}"
|
|
);
|
|
|
|
// Esc again: parent closes; back to normal composer state.
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(
|
|
chat.is_normal_backtrack_mode(),
|
|
"expected to be back in normal composer mode"
|
|
);
|
|
}
|
|
|
|
/// Opening base-branch picker from the review popup, pressing Esc returns to the
|
|
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
|
#[tokio::test]
|
|
async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Open the Review presets parent popup.
|
|
chat.open_review_popup();
|
|
|
|
// Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine.
|
|
let cwd = std::env::temp_dir();
|
|
chat.show_review_branch_picker(&cwd).await;
|
|
|
|
// Verify child view header.
|
|
let header = render_bottom_first_row(&chat, /*width*/ 60);
|
|
assert!(
|
|
header.contains("Select a base branch"),
|
|
"expected branch picker header: {header:?}"
|
|
);
|
|
|
|
// Esc once: child view closes, parent remains.
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
let header = render_bottom_first_row(&chat, /*width*/ 60);
|
|
assert!(
|
|
header.contains("Select a review preset"),
|
|
"expected to return to parent review popup: {header:?}"
|
|
);
|
|
|
|
// Esc again: parent closes; back to normal composer state.
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(
|
|
chat.is_normal_backtrack_mode(),
|
|
"expected to be back in normal composer mode"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enter_submits_steer_while_review_is_running() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
handle_entered_review_mode(&mut chat, "current changes");
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.bottom_pane.set_composer_text(
|
|
"Steer submitted while /review was running.".to_string(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
assert_eq!(chat.input_queue.pending_steers.len(), 1);
|
|
assert_eq!(
|
|
chat.input_queue
|
|
.pending_steers
|
|
.front()
|
|
.unwrap()
|
|
.user_message
|
|
.text,
|
|
"Steer submitted while /review was running."
|
|
);
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "Steer submitted while /review was running.".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected running-turn steer submit, got {other:?}"),
|
|
}
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn review_queues_user_messages_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
handle_entered_review_mode(&mut chat, "current changes");
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.submit_user_message(UserMessage::from(
|
|
"Steer submitted while /review was running.".to_string(),
|
|
));
|
|
handle_error(
|
|
&mut chat,
|
|
"cannot steer a review turn",
|
|
Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
|
turn_kind: NonSteerableTurnKind::Review,
|
|
}),
|
|
);
|
|
|
|
let width: u16 = 80;
|
|
let height: u16 = 18;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
|
let desired_height = chat.desired_height(width).min(height);
|
|
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
|
|
term.draw(|f| {
|
|
chat.render(f.area(), f.buffer_mut());
|
|
})
|
|
.unwrap();
|
|
assert_chatwidget_snapshot!(
|
|
"review_queues_user_messages_snapshot",
|
|
normalize_snapshot_paths(term.backend().vt100().screen().contents())
|
|
);
|
|
}
|