mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Recover unsent question drafts when TUI turns end (#47422)
## What changed Clear pending asynchronous questions when a live turn completes, is interrupted, or fails, and append nonblank typed answers to the main composer in question order without submitting them. Preserve existing composer content and input restored after interruption, and retain large answers as paste placeholders. Cancel history previews and pending Vim commands during recovery while preserving undo grouping and dot-repeat behavior. Skip recovery during replay and keep input cleared after a misalignment policy violation. ## Testing Add regression tests and snapshots for open and collapsed drafts, buffered typing, answer ordering, repeated completion notifications, large pastes, interrupted queued input, and Vim undo/redo behavior. Update coverage for terminal errors and realtime question expiration. GitOrigin-RevId: fec109fe8aecb0735a3a5410fbb063b5c5c6e9a1
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay.
|
||||
//! Local submissions and committed desktop replies remove questions; arrival never steals focus.
|
||||
//! Live turn completion recovers unsent typed drafts before removing pending questions.
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Pending async questions retain drafts until handled locally or answered by another client.
|
||||
//! Pending async questions retain drafts until handled locally, answered by another client, or a live turn ends.
|
||||
//! Message IDs survive removal so replay cannot reopen an answered or skipped question.
|
||||
|
||||
use super::*;
|
||||
@@ -206,6 +206,20 @@ impl AsyncQuestions {
|
||||
self.composer.reset_vim_mode();
|
||||
}
|
||||
|
||||
/// Recover unsent typed answers before clearing all pending questions.
|
||||
pub(crate) fn take_pending_drafts(&mut self) -> Vec<String> {
|
||||
self.save_current_draft();
|
||||
let drafts = self
|
||||
.state
|
||||
.pending
|
||||
.iter()
|
||||
.map(|question| question.draft.text_with_pending().trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect();
|
||||
self.clear_pending();
|
||||
drafts
|
||||
}
|
||||
|
||||
pub(crate) fn capture(&mut self) -> QuestionState {
|
||||
self.composer.cancel_history_search();
|
||||
self.save_current_draft();
|
||||
|
||||
@@ -28,6 +28,25 @@ fn editor() -> AsyncQuestions {
|
||||
editor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_pending_drafts_preserves_order_expands_pastes_and_skips_blank_drafts() {
|
||||
let mut editor = editor();
|
||||
editor.set_expanded(/*expanded*/ true);
|
||||
let large_answer = "typed answer ".repeat(/*n*/ 200);
|
||||
editor.handle_paste(large_answer.clone());
|
||||
editor.navigate(/*forward*/ true);
|
||||
editor.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
|
||||
editor.handle_paste(" other answer ".into());
|
||||
editor.append("blank", &[question("Blank?", /*options*/ None)]);
|
||||
|
||||
assert_eq!(
|
||||
editor.take_pending_drafts(),
|
||||
vec![large_answer.trim().to_string(), "other answer".to_string()]
|
||||
);
|
||||
assert_eq!((editor.unanswered_count(), editor.expanded), (0, false));
|
||||
assert!(editor.submission.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_preserves_drafts_and_does_not_reopen_handled_questions() {
|
||||
let mut original = editor();
|
||||
|
||||
@@ -85,6 +85,13 @@
|
||||
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
|
||||
//! `ChatWidget` dispatches the command.
|
||||
//!
|
||||
//! # Question Draft Recovery
|
||||
//!
|
||||
//! Live terminal turns recover typed, unsubmitted question answers into the main composer.
|
||||
//! The append flushes buffered input, dismisses unused sparkle eligibility, and adds a newline
|
||||
//! after existing text. The separator and recovered answer form one Vim edit; large answers
|
||||
//! use atomic paste placeholders backed by their original text.
|
||||
//!
|
||||
//! # Startup Draft Handoff
|
||||
//!
|
||||
//! Startup uses a provisional plain-text composer: editing remains available while its owner
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Embed the composer in question rows, resetting history navigation when drafts change.
|
||||
//! Recovered appends cancel pending Vim commands before moving the cursor and stay out of dot repeat.
|
||||
//! Normal-mode recovery is one undoable edit; active insert/replace sessions keep their grouping.
|
||||
//! Paste payloads stay intact and sparkle stays dismissed.
|
||||
|
||||
use super::super::textarea::VimPersistentState;
|
||||
use super::*;
|
||||
|
||||
impl ComposerDraft {
|
||||
@@ -14,6 +18,39 @@ impl ComposerDraft {
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
pub(in crate::bottom_pane) fn append_recovered_drafts(&mut self, drafts: &str) {
|
||||
self.flush_pending_input();
|
||||
self.dismiss_sparkle();
|
||||
if self.draft.textarea.is_vim_operator_pending() {
|
||||
self.draft.textarea.enter_vim_normal_mode();
|
||||
}
|
||||
self.finish_vim_edit();
|
||||
let mut vim_state = VimPersistentState::default();
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
let started_vim_edit = self.begin_direct_vim_edit();
|
||||
self.move_cursor_to_end();
|
||||
if !self.current_text().is_empty() {
|
||||
self.insert_str("\n");
|
||||
}
|
||||
let char_count = drafts.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
let placeholder = self.next_large_paste_placeholder(char_count);
|
||||
self.draft.textarea.insert_element(&placeholder);
|
||||
self.draft.pending_pastes.push((placeholder, drafts.into()));
|
||||
self.sync_popups();
|
||||
} else {
|
||||
self.insert_str(drafts);
|
||||
}
|
||||
self.draft
|
||||
.textarea
|
||||
.swap_vim_persistent_state(&mut vim_state);
|
||||
if started_vim_edit {
|
||||
self.finish_vim_edit();
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::bottom_pane) fn restore_inline_draft(&mut self, draft: ComposerDraft) {
|
||||
self.history.reset_navigation();
|
||||
self.restore_draft(draft);
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs
|
||||
assertion_line: 900
|
||||
expression: "text(&draw(&pane.composer, 80, now).0)"
|
||||
---
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Covers the original starfield appearance, protected content, and composer eligibility.
|
||||
|
||||
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use super::*;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
@@ -874,7 +875,7 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_
|
||||
canceled.select_sparkle_model("astra", &enabled());
|
||||
assert!(!dots(&draw(&canceled.composer, /*width*/ 80, now).0).is_empty());
|
||||
|
||||
for input in ["paste draft", "inserted"] {
|
||||
for input in ["paste draft", "inserted", "recovered"] {
|
||||
let mut pane = pane();
|
||||
pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled());
|
||||
if input == "inserted" {
|
||||
@@ -884,6 +885,9 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_
|
||||
InputResult::Command(SlashCommand::Mention)
|
||||
));
|
||||
pane.composer.insert_str("@");
|
||||
} else if input == "recovered" {
|
||||
pane.composer
|
||||
.append_recovered_drafts(&"x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1));
|
||||
} else {
|
||||
pane.handle_paste(input.to_string());
|
||||
}
|
||||
@@ -892,6 +896,12 @@ fn canceled_commands_remain_eligible_but_inserted_content_and_ordinary_paste_do_
|
||||
pane.select_sparkle_model("astra", &enabled());
|
||||
assert_eq!(pane.composer.sparkle.draft.get(), SparkleDraft::Dismissed);
|
||||
assert!(dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty());
|
||||
if input == "recovered" {
|
||||
insta::assert_snapshot!(
|
||||
"cleared_recovered_draft_keeps_sparkle_dismissed",
|
||||
text(&draw(&pane.composer, /*width*/ 80, now).0)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +78,78 @@ fn undo_and_redo_group_complete_vim_edits() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_answers_undo_as_one_vim_edit() {
|
||||
for answer in [
|
||||
"answer".to_owned(),
|
||||
"x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1),
|
||||
] {
|
||||
let mut composer = vim_composer("abc");
|
||||
keys(&mut composer, "iX");
|
||||
escape(&mut composer);
|
||||
let before = composer.draft_snapshot();
|
||||
composer.append_recovered_drafts(&answer);
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
keys(&mut composer, ".");
|
||||
assert_eq!(composer.current_text(), "XXabc");
|
||||
|
||||
for (command, mode, edited) in [("iX", "Insert", "Xabc"), ("RX", "Replace", "Xbc")] {
|
||||
let mut composer = vim_composer("abc");
|
||||
let before = composer.draft_snapshot();
|
||||
keys(&mut composer, command);
|
||||
composer.append_recovered_drafts(&answer);
|
||||
assert_eq!(
|
||||
(
|
||||
composer.current_text_with_pending(),
|
||||
composer.draft.textarea.vim_mode_label(),
|
||||
),
|
||||
(format!("{edited}\n{answer}"), Some(mode))
|
||||
);
|
||||
escape(&mut composer);
|
||||
let after = composer.draft_snapshot();
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
ctrl_r(&mut composer);
|
||||
assert_eq!(composer.draft_snapshot(), after);
|
||||
keys(&mut composer, "u");
|
||||
keys(&mut composer, ".");
|
||||
assert_eq!(composer.current_text(), edited);
|
||||
}
|
||||
for command in ["", "d", "/draft", "?draft", "d/draft", "c?draft", "y/draft"] {
|
||||
let mut composer = vim_composer("existing draft");
|
||||
let before = composer.draft_snapshot();
|
||||
keys(&mut composer, command);
|
||||
composer.append_recovered_drafts(&answer);
|
||||
assert_eq!(
|
||||
(
|
||||
composer.current_text_with_pending(),
|
||||
composer.draft.textarea.vim_query().is_some(),
|
||||
composer.draft.textarea.is_vim_operator_pending(),
|
||||
),
|
||||
(format!("existing draft\n{answer}"), false, false)
|
||||
);
|
||||
let after = composer.draft_snapshot();
|
||||
keys(&mut composer, "u");
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
ctrl_r(&mut composer);
|
||||
assert_eq!(composer.draft_snapshot(), after);
|
||||
}
|
||||
}
|
||||
snapshot_composer_state_with_width(
|
||||
"recovered_answer_cancels_vim_search",
|
||||
/*width*/ 40,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
|composer| {
|
||||
composer.set_disable_paste_burst(/*disabled*/ true);
|
||||
composer.set_text_content("existing draft".into(), Vec::new(), Vec::new());
|
||||
composer.set_vim_enabled(/*enabled*/ true);
|
||||
keys(composer, "d/draft");
|
||||
composer.append_recovered_drafts("answer");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_preserves_repeat_and_pending_operator_bindings() {
|
||||
let mut composer = vim_composer("alpha beta gamma");
|
||||
|
||||
@@ -42,6 +42,28 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Capture answers and settle the main draft before turn finalization can restore input.
|
||||
pub(crate) fn take_question_drafts(&mut self) -> Option<Vec<String>> {
|
||||
let questions = self.questions.as_mut()?;
|
||||
// The question editor closes at turn end; recover its typed answer, not a history preview.
|
||||
questions.composer.cancel_history_search();
|
||||
let drafts = questions.take_pending_drafts();
|
||||
if !drafts.is_empty() {
|
||||
self.composer.cancel_history_search();
|
||||
self.composer.flush_pending_input();
|
||||
}
|
||||
Some(drafts)
|
||||
}
|
||||
|
||||
/// Append recovered answers after turn finalization, retaining existing composer elements.
|
||||
pub(crate) fn append_question_drafts(&mut self, drafts: &[String]) {
|
||||
if drafts.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.composer.append_recovered_drafts(&drafts.join("\n"));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn question_summary(&self, now: Instant) -> Option<Vec<Line<'static>>> {
|
||||
let questions = self
|
||||
.questions
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› existing draft "
|
||||
" answer "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 100% context left | Vim: Normal "
|
||||
@@ -408,6 +408,7 @@ impl ChatWidget {
|
||||
// this TUI already rendered locally. Once that turn ends, another
|
||||
// client can submit the same text and it still needs its own user cell.
|
||||
self.last_rendered_user_message_display = None;
|
||||
let mut question_drafts = None;
|
||||
let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion;
|
||||
self.thread_usage.replaying_turn_completion = replay_kind.is_some();
|
||||
match notification.turn.status {
|
||||
@@ -453,6 +454,9 @@ impl ChatWidget {
|
||||
{
|
||||
self.speak_completed_realtime_delegation(¬ification.turn.id, item);
|
||||
}
|
||||
if replay_kind.is_none() {
|
||||
question_drafts = self.bottom_pane.take_question_drafts();
|
||||
}
|
||||
self.last_non_retry_error = None;
|
||||
let completion = self.completion_cell(¬ification.turn, replay_kind);
|
||||
self.on_task_complete(
|
||||
@@ -462,6 +466,9 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
TurnStatus::Interrupted => {
|
||||
if replay_kind.is_none() {
|
||||
question_drafts = self.bottom_pane.take_question_drafts();
|
||||
}
|
||||
self.last_non_retry_error = None;
|
||||
let reason = if self
|
||||
.turn_lifecycle
|
||||
@@ -474,6 +481,9 @@ impl ChatWidget {
|
||||
self.on_interrupted_turn(reason);
|
||||
}
|
||||
TurnStatus::Failed => {
|
||||
if replay_kind.is_none() {
|
||||
question_drafts = self.bottom_pane.take_question_drafts();
|
||||
}
|
||||
if let Some(error) = notification.turn.error {
|
||||
if replay_kind.is_none()
|
||||
&& error.codex_error_info
|
||||
@@ -500,6 +510,14 @@ impl ChatWidget {
|
||||
}
|
||||
TurnStatus::InProgress => {}
|
||||
}
|
||||
if let Some(drafts) = question_drafts
|
||||
&& !self.has_misalignment_policy_violation()
|
||||
{
|
||||
// Interruption can restore queued input into the composer during finalization.
|
||||
self.bottom_pane.append_question_drafts(&drafts);
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
}
|
||||
if replay_kind.is_none() {
|
||||
self.finish_realtime_turn(¬ification.turn.id);
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ async fn delegation_started_before_peer_connection_keeps_its_voice_origin() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech() {
|
||||
async fn delegated_async_question_stays_local_and_expires_when_its_turn_ends() {
|
||||
let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
|
||||
let thread_id = activate_voice(&mut chat);
|
||||
let turn_id = "question-turn";
|
||||
@@ -199,6 +199,13 @@ async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech
|
||||
};
|
||||
start_item(&mut chat, thread_id, turn_id, answer.clone());
|
||||
complete_item(&mut chat, thread_id, turn_id, answer.clone());
|
||||
assert_eq!(
|
||||
chat.bottom_pane
|
||||
.questions
|
||||
.as_ref()
|
||||
.map(|editor| editor.unanswered_count()),
|
||||
Some(1)
|
||||
);
|
||||
finish_turn(
|
||||
&mut chat,
|
||||
thread_id,
|
||||
@@ -212,7 +219,7 @@ async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech
|
||||
.questions
|
||||
.as_ref()
|
||||
.map(|editor| editor.unanswered_count()),
|
||||
Some(1)
|
||||
Some(0)
|
||||
);
|
||||
assert!(
|
||||
ops.try_recv().is_err(),
|
||||
|
||||
@@ -314,6 +314,9 @@ pub(super) use helpers::*;
|
||||
#[path = "tests/questions_tests.rs"]
|
||||
mod questions_tests;
|
||||
|
||||
#[path = "tests/question_turn_end_tests.rs"]
|
||||
mod question_turn_end_tests;
|
||||
|
||||
#[path = "tests/list_spacing_tests.rs"]
|
||||
mod list_spacing_tests;
|
||||
|
||||
|
||||
@@ -1555,7 +1555,10 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
|
||||
phase: Some(MessagePhase::FinalAnswer),
|
||||
memory_citation: None,
|
||||
delivery: None,
|
||||
questions: None,
|
||||
questions: Some(vec![codex_protocol::items::AsyncUserInputQuestion {
|
||||
title: "Which way?".into(),
|
||||
options: None,
|
||||
}]),
|
||||
}];
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
@@ -1586,6 +1589,7 @@ async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(consolidations.len(), 1);
|
||||
assert_eq!(chat.bottom_pane.question_editor().unanswered_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -71,6 +71,16 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() {
|
||||
chat.queue_user_message(UserMessage::from("queued follow-up"));
|
||||
chat.bottom_pane
|
||||
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
|
||||
chat.add_async_questions(
|
||||
"question",
|
||||
&[codex_protocol::items::AsyncUserInputQuestion {
|
||||
title: "Which way?".into(),
|
||||
options: None,
|
||||
}],
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
chat.bottom_pane.handle_paste("answer".into());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
handle_error(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Live terminal turns recover open and collapsed question drafts into the composer.
|
||||
|
||||
use super::*;
|
||||
use codex_protocol::items::AsyncUserInputQuestion;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn questions(chat: &mut ChatWidget, message_id: &str) {
|
||||
chat.add_async_questions(
|
||||
message_id,
|
||||
&[AsyncUserInputQuestion {
|
||||
title: "Which way?".into(),
|
||||
options: None,
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn question_turn_end_appends_open_drafts_in_order_once() {
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.show_welcome_banner = false;
|
||||
handle_turn_started(&mut chat, "turn");
|
||||
chat.bottom_pane
|
||||
.record_replayed_user_message_history(crate::bottom_pane::HistoryEntry::new(
|
||||
"Earlier answer".into(),
|
||||
));
|
||||
let element = TextElement::new((0..5).into(), Some("$tool".into()));
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"$tool existing draft".into(),
|
||||
vec![element.clone()],
|
||||
Vec::new(),
|
||||
);
|
||||
chat.bottom_pane.set_composer_cursor(/*cursor*/ 0);
|
||||
questions(&mut chat, "first");
|
||||
questions(&mut chat, "second");
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
chat.bottom_pane.handle_paste(" Over-ear ".into());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
chat.bottom_pane.handle_paste("Under 250".into());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('e')));
|
||||
|
||||
handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None);
|
||||
assert_eq!(
|
||||
(
|
||||
chat.bottom_pane.composer_text(),
|
||||
chat.bottom_pane.composer_text_elements(),
|
||||
chat.bottom_pane.question_editor().unanswered_count()
|
||||
),
|
||||
(
|
||||
"$tool existing draft\nOver-ear\nUnder 250".into(),
|
||||
vec![element],
|
||||
0
|
||||
)
|
||||
);
|
||||
assert!(ops.try_recv().is_err());
|
||||
insta::assert_snapshot!(
|
||||
"question_turn_end_recovered_composer",
|
||||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||||
);
|
||||
|
||||
handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None);
|
||||
assert_recovered_draft(&mut chat, "$tool existing draft\nOver-ear\nUnder 250");
|
||||
|
||||
questions(&mut chat, "large");
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
let large_answer = "long answer ".repeat(/*n*/ 200);
|
||||
chat.bottom_pane.handle_paste(large_answer.clone());
|
||||
handle_turn_completed(&mut chat, "ended-turn", /*duration_ms*/ None);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text_with_pending(),
|
||||
format!(
|
||||
"$tool existing draft\nOver-ear\nUnder 250\n{}",
|
||||
large_answer.trim()
|
||||
)
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.composer_pending_pastes().len(), 1);
|
||||
insta::assert_snapshot!(
|
||||
"question_turn_end_large_draft",
|
||||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn question_turn_end_recovers_collapsed_drafts_on_completion_and_failure() {
|
||||
for status in [AppServerTurnStatus::Completed, AppServerTurnStatus::Failed] {
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false);
|
||||
handle_turn_started(&mut chat, "turn");
|
||||
questions(&mut chat, "question");
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
// Leave typing buffered, then collapse before completion.
|
||||
for ch in "answer".chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
|
||||
for ch in "main".chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
}
|
||||
if status == AppServerTurnStatus::Failed {
|
||||
chat.input_queue
|
||||
.queued_user_messages
|
||||
.push_back(UserMessage::from("queued prompt").into());
|
||||
handle_error(&mut chat, "Turn failed", /*codex_error_info*/ None);
|
||||
assert!(matches!(ops.try_recv().unwrap(), Op::UserTurn { .. }));
|
||||
assert_eq!(
|
||||
chat.capture_thread_input_state()
|
||||
.unwrap()
|
||||
.composer
|
||||
.unwrap()
|
||||
.text,
|
||||
"main\nanswer"
|
||||
);
|
||||
}
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||||
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
turn: app_server_turn(
|
||||
"turn",
|
||||
status.clone(),
|
||||
/*duration_ms*/ None,
|
||||
(status == AppServerTurnStatus::Failed).then(|| AppServerTurnError {
|
||||
misalignment: None,
|
||||
message: "Turn failed".into(),
|
||||
codex_error_info: None,
|
||||
additional_details: None,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert_recovered_draft(&mut chat, "main\nanswer");
|
||||
assert!(ops.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn question_turn_end_recovers_after_interruption_restores_queued_input() {
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
handle_turn_started(&mut chat, "turn");
|
||||
chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("main draft".into(), Vec::new(), Vec::new());
|
||||
questions(&mut chat, "question");
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
|
||||
chat.bottom_pane.handle_paste("answer".into());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('+')));
|
||||
chat.input_queue
|
||||
.queued_user_messages
|
||||
.push_back(UserMessage::from("queued prompt").into());
|
||||
chat.input_queue
|
||||
.pending_steers
|
||||
.push_back(pending_steer("pending steer"));
|
||||
handle_turn_interrupted(&mut chat, "turn");
|
||||
assert_recovered_draft(
|
||||
&mut chat,
|
||||
"pending steer\nqueued prompt\nmain draft+\nanswer",
|
||||
);
|
||||
assert!(!chat.has_queued_follow_up_messages());
|
||||
assert!(ops.try_recv().is_err());
|
||||
}
|
||||
|
||||
fn assert_recovered_draft(chat: &mut ChatWidget, text: &str) {
|
||||
assert_eq!(
|
||||
(
|
||||
chat.bottom_pane.composer_text(),
|
||||
chat.bottom_pane.question_editor().unanswered_count()
|
||||
),
|
||||
(text.into(), 0)
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/question_turn_end_tests.rs
|
||||
assertion_line: 86
|
||||
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 80))"
|
||||
---
|
||||
› $tool existing draft
|
||||
Over-ear
|
||||
Under 250
|
||||
[Pasted Content 2399 chars]
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/question_turn_end_tests.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
› $tool existing draft
|
||||
Over-ear
|
||||
Under 250
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! This module owns task start/completion state, runtime metrics, plan updates,
|
||||
//! and completion metadata rendering.
|
||||
//! Terminal errors retain live question drafts across queued input delivery.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -469,12 +470,19 @@ impl ChatWidget {
|
||||
message: String,
|
||||
codex_error_info: Option<AppServerCodexErrorInfo>,
|
||||
) {
|
||||
if codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) {
|
||||
self.on_misalignment_policy_violation();
|
||||
} else if codex_error_info
|
||||
if codex_error_info
|
||||
.as_ref()
|
||||
.is_some_and(|info| self.handle_app_server_steer_rejected_error(info))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let question_drafts = if self.thread_usage.replaying_turn_completion {
|
||||
None
|
||||
} else {
|
||||
self.bottom_pane.take_question_drafts()
|
||||
};
|
||||
if codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) {
|
||||
self.on_misalignment_policy_violation();
|
||||
} else if codex_error_info
|
||||
.as_ref()
|
||||
.is_some_and(is_app_server_cyber_policy_error)
|
||||
@@ -506,6 +514,13 @@ impl ChatWidget {
|
||||
} else {
|
||||
self.on_error(message);
|
||||
}
|
||||
if let Some(drafts) = question_drafts
|
||||
&& !self.has_misalignment_policy_violation()
|
||||
{
|
||||
self.bottom_pane.append_question_drafts(&drafts);
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_warning(&mut self, message: impl Into<String>) {
|
||||
|
||||
Reference in New Issue
Block a user