tui/core: partially submit committed request_user_input answers on interrupt

This commit is contained in:
Charles Cunningham
2026-03-02 18:01:21 -08:00
parent 0bb152b01d
commit a5bcf4a7b8
10 changed files with 415 additions and 77 deletions
@@ -587,6 +587,7 @@ pub(crate) async fn apply_bespoke_event_handling(
);
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
@@ -1916,6 +1917,7 @@ async fn on_request_user_input_response(
error!("request failed with client error: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
@@ -1932,6 +1934,7 @@ async fn on_request_user_input_response(
error!("request failed: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
@@ -1966,6 +1969,7 @@ async fn on_request_user_input_response(
)
})
.collect(),
interrupted: false,
};
if let Err(err) = conversation
+40 -1
View File
@@ -2782,6 +2782,14 @@ impl Session {
sub_id: &str,
response: RequestUserInputResponse,
) {
if response.interrupted {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut() {
let mut ts = at.turn_state.lock().await;
ts.mark_request_user_input_interrupted();
}
}
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -2802,6 +2810,17 @@ impl Session {
}
}
pub async fn take_request_user_input_interrupted(&self) -> bool {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.take_request_user_input_interrupted()
}
None => false,
}
}
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -5035,8 +5054,20 @@ pub(crate) async fn run_turn(
Ok(sampling_request_output) => {
let SamplingRequestResult {
needs_follow_up,
request_user_input_interrupted,
last_agent_message: sampling_request_last_agent_message,
} = sampling_request_output;
if request_user_input_interrupted {
cancellation_token.cancel();
sess.finish_turn_without_completion_event(turn_context.as_ref())
.await;
sess.emit_turn_aborted_without_rollout_flush(
&turn_context,
TurnAbortReason::Interrupted,
)
.await;
break;
}
let total_usage_tokens = sess.get_total_token_usage().await;
let token_limit_reached = total_usage_tokens >= auto_compact_limit;
@@ -5633,6 +5664,7 @@ async fn built_tools(
#[derive(Debug)]
struct SamplingRequestResult {
needs_follow_up: bool,
request_user_input_interrupted: bool,
last_agent_message: Option<String>,
}
@@ -6222,7 +6254,7 @@ async fn try_run_sampling_request(
let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode);
let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id));
let receiving_span = trace_span!("receiving_stream");
let outcome: CodexResult<SamplingRequestResult> = loop {
let mut outcome: CodexResult<SamplingRequestResult> = loop {
let handle_responses = trace_span!(
parent: &receiving_span,
"handle_responses",
@@ -6396,6 +6428,7 @@ async fn try_run_sampling_request(
break Ok(SamplingRequestResult {
needs_follow_up,
request_user_input_interrupted: false,
last_agent_message,
});
}
@@ -6488,6 +6521,12 @@ async fn try_run_sampling_request(
.await;
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
if let Ok(result) = outcome.as_mut()
&& sess.take_request_user_input_interrupted().await
{
result.needs_follow_up = false;
result.request_user_input_interrupted = true;
}
if should_emit_turn_diff {
let unified_diff = {
+2
View File
@@ -430,6 +430,7 @@ where
_ = cancel_token.cancelled() => {
let empty = RequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
};
parent_session
.notify_user_input_response(sub_id, empty.clone())
@@ -438,6 +439,7 @@ where
}
response = fut => response.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
}),
}
}
@@ -104,12 +104,14 @@ async fn should_install_mcp_dependencies(
_ = cancellation_token.cancelled() => {
let empty = RequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
};
sess.notify_user_input_response(sub_id, empty.clone()).await;
empty
}
response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
}),
};
@@ -133,6 +133,7 @@ pub(crate) async fn request_skill_dependencies(
.await
.unwrap_or_else(|| RequestUserInputResponse {
answers: HashMap::new(),
interrupted: false,
});
if response.answers.is_empty() {
+10
View File
@@ -73,6 +73,7 @@ pub(crate) struct TurnState {
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
request_user_input_interrupted: bool,
}
impl TurnState {
@@ -96,6 +97,7 @@ impl TurnState {
self.pending_user_input.clear();
self.pending_dynamic_tools.clear();
self.pending_input.clear();
self.request_user_input_interrupted = false;
}
pub(crate) fn insert_pending_user_input(
@@ -145,6 +147,14 @@ impl TurnState {
pub(crate) fn has_pending_input(&self) -> bool {
!self.pending_input.is_empty()
}
pub(crate) fn mark_request_user_input_interrupted(&mut self) {
self.request_user_input_interrupted = true;
}
pub(crate) fn take_request_user_input_interrupted(&mut self) -> bool {
std::mem::take(&mut self.request_user_input_interrupted)
}
}
impl ActiveTurn {
+63 -30
View File
@@ -143,7 +143,7 @@ impl Session {
Arc::clone(&session_ctx),
ctx,
input,
task_cancellation_token.child_token(),
task_cancellation_token.clone(),
)
.await;
let sess = session_ctx.clone_session();
@@ -193,29 +193,8 @@ impl Session {
turn_context
.turn_metadata_state
.cancel_git_enrichment_task();
let mut active = self.active_turn.lock().await;
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut should_clear_active_turn = false;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
let mut ts = at.turn_state.lock().await;
pending_input = ts.take_pending_input();
should_clear_active_turn = true;
}
if should_clear_active_turn {
*active = None;
}
drop(active);
if !pending_input.is_empty() {
let pending_response_items = pending_input
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
self.record_conversation_items(turn_context.as_ref(), &pending_response_items)
.await;
}
self.finish_turn_without_completion_event(turn_context.as_ref())
.await;
let event = EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_context.sub_id.clone(),
last_agent_message,
@@ -249,6 +228,31 @@ impl Session {
.await;
}
pub(crate) async fn finish_turn_without_completion_event(&self, turn_context: &TurnContext) {
let mut active = self.active_turn.lock().await;
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut should_clear_active_turn = false;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
let mut ts = at.turn_state.lock().await;
pending_input = ts.take_pending_input();
should_clear_active_turn = true;
}
if should_clear_active_turn {
*active = None;
}
drop(active);
if !pending_input.is_empty() {
let pending_response_items = pending_input
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
self.record_conversation_items(turn_context, &pending_response_items)
.await;
}
}
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
let sub_id = task.turn_context.sub_id.clone();
if task.cancellation_token.is_cancelled() {
@@ -276,7 +280,34 @@ impl Session {
session_task
.abort(session_ctx, Arc::clone(&task.turn_context))
.await;
self.emit_turn_aborted(task.turn_context.as_ref(), reason)
.await;
}
pub(crate) async fn emit_turn_aborted(
self: &Arc<Self>,
turn_context: &TurnContext,
reason: TurnAbortReason,
) {
self.emit_turn_aborted_inner(turn_context, reason, true)
.await;
}
pub(crate) async fn emit_turn_aborted_without_rollout_flush(
self: &Arc<Self>,
turn_context: &TurnContext,
reason: TurnAbortReason,
) {
self.emit_turn_aborted_inner(turn_context, reason, false)
.await;
}
async fn emit_turn_aborted_inner(
self: &Arc<Self>,
turn_context: &TurnContext,
reason: TurnAbortReason,
flush_rollout_before_event: bool,
) {
if reason == TurnAbortReason::Interrupted {
let marker = ResponseItem::Message {
id: None,
@@ -289,20 +320,22 @@ impl Session {
end_turn: None,
phase: None,
};
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
self.record_into_history(std::slice::from_ref(&marker), turn_context)
.await;
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
.await;
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
self.flush_rollout().await;
if flush_rollout_before_event {
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
self.flush_rollout().await;
}
}
let event = EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(task.turn_context.sub_id.clone()),
turn_id: Some(turn_context.sub_id.clone()),
reason,
});
self.send_event(task.turn_context.as_ref(), event).await;
self.send_event(turn_context, event).await;
}
}
+162 -1
View File
@@ -19,6 +19,7 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
@@ -166,7 +167,10 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
answers: vec!["yes".to_string()],
},
);
let response = RequestUserInputResponse { answers };
let response = RequestUserInputResponse {
answers,
interrupted: false,
};
codex
.submit(Op::UserInputAnswer {
id: request.turn_id.clone(),
@@ -191,6 +195,163 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_interrupted_response_preserves_tool_output() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let builder = test_codex();
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder
.with_config(|config| {
config.features.enable(Feature::CollaborationModes);
})
.build(&server)
.await?;
let call_id = "user-input-call-interrupt";
let request_args = json!({
"questions": [{
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
}, {
"label": "No",
"description": "Stop and revisit the approach."
}]
}]
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "request_user_input", &request_args),
ev_completed("resp-1"),
]);
let follow_up_response = sse(vec![
ev_assistant_message("msg-1", "next turn"),
ev_completed("resp-2"),
]);
let response_mock = mount_sse_sequence(&server, vec![first_response, follow_up_response]).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please confirm".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model.clone(),
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
personality: None,
})
.await?;
let request = wait_for_event_match(&codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
assert_eq!(request.call_id, call_id);
let mut answers = HashMap::new();
answers.insert(
"confirm_path".to_string(),
RequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
);
codex
.submit(Op::UserInputAnswer {
id: request.turn_id.clone(),
response: RequestUserInputResponse {
answers,
interrupted: true,
},
})
.await?;
let terminal_event = wait_for_event_match(&codex, |event| match event {
EventMsg::TurnAborted(_) => Some("aborted"),
EventMsg::TurnComplete(_) => Some("complete"),
_ => None,
})
.await;
assert_eq!(terminal_event, "aborted", "expected interrupted turn");
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "follow up".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
personality: None,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = response_mock.requests();
let request_with_output = requests
.iter()
.find(|req| req.function_call_output_text(call_id).is_some())
.expect("expected request_user_input function_call_output in later request");
let output_text = call_output(request_with_output, call_id);
assert!(
!output_text.contains("aborted by user"),
"request_user_input output should not be replaced by synthetic abort text"
);
let output_json: Value = serde_json::from_str(&output_text)?;
assert_eq!(
output_json,
json!({
"answers": {
"confirm_path": { "answers": ["yes"] }
},
"interrupted": true
})
);
Ok(())
}
async fn assert_request_user_input_rejected<F>(mode_name: &str, build_mode: F) -> anyhow::Result<()>
where
F: FnOnce(String) -> CollaborationMode,
@@ -41,6 +41,8 @@ pub struct RequestUserInputAnswer {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct RequestUserInputResponse {
pub answers: HashMap<String, RequestUserInputAnswer>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub interrupted: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
@@ -33,6 +33,7 @@ use crate::render::renderable::Renderable;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::TextElement;
use unicode_width::UnicodeWidthStr;
@@ -710,46 +711,87 @@ impl RequestUserInputOverlay {
}
}
fn answer_for_question(
&self,
idx: usize,
question: &RequestUserInputQuestion,
committed_only: bool,
) -> Option<RequestUserInputAnswer> {
let answer_state = &self.answers[idx];
if committed_only && !answer_state.answer_committed {
return None;
}
let options = question.options.as_ref();
// For option questions we may still produce no selection.
let selected_idx =
if options.is_some_and(|opts| !opts.is_empty()) && answer_state.answer_committed {
answer_state.options_state.selected_idx
} else {
None
};
// Notes are appended as extra answers. For freeform questions, only submit when
// the user explicitly committed the draft.
let notes = if answer_state.answer_committed {
answer_state.draft.text_with_pending().trim().to_string()
} else {
String::new()
};
let selected_label = selected_idx
.and_then(|selected_idx| Self::option_label_for_index(question, selected_idx));
let mut answer_list = selected_label.into_iter().collect::<Vec<_>>();
if !notes.is_empty() {
answer_list.push(format!("user_note: {notes}"));
}
Some(RequestUserInputAnswer {
answers: answer_list,
})
}
fn submit_committed_answers_for_interrupt(&mut self) {
self.confirm_unanswered = None;
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
if let Some(answer) = self.answer_for_question(idx, question, true) {
answers.insert(question.id.clone(), answer);
}
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::UserInputAnswer {
id: self.request.turn_id.clone(),
response: RequestUserInputResponse {
answers: answers.clone(),
interrupted: true,
},
}));
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
answers,
interrupted: true,
},
)));
}
/// Build the response payload and dispatch it to the app.
fn submit_answers(&mut self) {
self.confirm_unanswered = None;
self.save_current_draft();
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
let answer_state = &self.answers[idx];
let options = question.options.as_ref();
// For option questions we may still produce no selection.
let selected_idx =
if options.is_some_and(|opts| !opts.is_empty()) && answer_state.answer_committed {
answer_state.options_state.selected_idx
} else {
None
};
// Notes are appended as extra answers. For freeform questions, only submit when
// the user explicitly committed the draft.
let notes = if answer_state.answer_committed {
answer_state.draft.text_with_pending().trim().to_string()
} else {
String::new()
};
let selected_label = selected_idx
.and_then(|selected_idx| Self::option_label_for_index(question, selected_idx));
let mut answer_list = selected_label.into_iter().collect::<Vec<_>>();
if !notes.is_empty() {
answer_list.push(format!("user_note: {notes}"));
if let Some(answer) = self.answer_for_question(idx, question, false) {
answers.insert(question.id.clone(), answer);
}
answers.insert(
question.id.clone(),
RequestUserInputAnswer {
answers: answer_list,
},
);
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::UserInputAnswer {
id: self.request.turn_id.clone(),
response: RequestUserInputResponse {
answers: answers.clone(),
interrupted: false,
},
}));
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
@@ -1005,9 +1047,7 @@ impl BottomPaneView for RequestUserInputOverlay {
self.clear_notes_and_focus_options();
return;
}
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.submit_committed_answers_for_interrupt();
self.done = true;
return;
}
@@ -1221,9 +1261,7 @@ impl BottomPaneView for RequestUserInputOverlay {
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.confirm_unanswered_active() {
self.close_unanswered_confirmation();
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.submit_committed_answers_for_interrupt();
self.done = true;
return CancellationEvent::Handled;
}
@@ -1232,9 +1270,7 @@ impl BottomPaneView for RequestUserInputOverlay {
return CancellationEvent::Handled;
}
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.submit_committed_answers_for_interrupt();
self.done = true;
CancellationEvent::Handled
}
@@ -1298,16 +1334,28 @@ mod tests {
(AppEventSender::new(tx_raw), rx)
}
fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) {
let event = rx.try_recv().expect("expected interrupt AppEvent");
fn expect_partial_interrupt_submission(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
expected_turn_id: &str,
) -> RequestUserInputResponse {
let event = rx.try_recv().expect("expected partial answer AppEvent");
let AppEvent::CodexOp(op) = event else {
panic!("expected CodexOp");
};
assert_eq!(op, Op::Interrupt);
let Op::UserInputAnswer { id, response } = op else {
panic!("expected UserInputAnswer");
};
assert_eq!(id, expected_turn_id);
assert!(response.interrupted, "expected interrupted response");
let event = rx.try_recv().expect("expected history cell");
assert!(matches!(event, AppEvent::InsertHistoryCell(_)));
assert!(
rx.try_recv().is_err(),
"unexpected AppEvents before interrupt completion"
"unexpected AppEvents after interrupted submission"
);
response
}
fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion {
@@ -1529,7 +1577,8 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(overlay.done, "expected overlay to be done");
expect_interrupt_only(&mut rx);
let response = expect_partial_interrupt_submission(&mut rx, "turn-1");
assert!(response.answers.is_empty(), "expected no committed answers");
}
#[test]
@@ -1910,7 +1959,11 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
let response = expect_partial_interrupt_submission(&mut rx, "turn-1");
assert!(
response.answers.is_empty(),
"expected no committed answers for empty freeform question"
);
}
#[test]
@@ -1927,7 +1980,8 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert_eq!(overlay.done, true);
expect_interrupt_only(&mut rx);
let response = expect_partial_interrupt_submission(&mut rx, "turn-1");
assert!(response.answers.is_empty(), "expected no committed answers");
}
#[test]
@@ -1988,7 +2042,7 @@ mod tests {
}
#[test]
fn esc_drops_committed_answers() {
fn esc_submits_only_committed_answers_before_interrupt() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
@@ -2012,7 +2066,37 @@ mod tests {
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
expect_interrupt_only(&mut rx);
let response = expect_partial_interrupt_submission(&mut rx, "turn-1");
let answer = response
.answers
.get("q1")
.expect("missing committed answer");
assert_eq!(answer.answers, vec!["Option 1".to_string()]);
assert!(
!response.answers.contains_key("q2"),
"uncommitted freeform question should not be submitted"
);
}
#[test]
fn esc_does_not_submit_uncommitted_freeform_text() {
let (tx, mut rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
let response = expect_partial_interrupt_submission(&mut rx, "turn-1");
assert!(
response.answers.is_empty(),
"uncommitted freeform text should not be submitted"
);
}
#[test]