diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2f293c9347..06cfc6fbbc 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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 diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 202eace800..0368858f01 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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, } @@ -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 = loop { + let mut outcome: CodexResult = 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 = { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1e8dd71419..fef713cc9f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -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, }), } } diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index f15bb6ec57..699d269ec6 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -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, }), }; diff --git a/codex-rs/core/src/skills/env_var_dependencies.rs b/codex-rs/core/src/skills/env_var_dependencies.rs index 00f5bad8cc..3b52087e18 100644 --- a/codex-rs/core/src/skills/env_var_dependencies.rs +++ b/codex-rs/core/src/skills/env_var_dependencies.rs @@ -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() { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ccc50d066b..a0efd5cbcb 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -73,6 +73,7 @@ pub(crate) struct TurnState { pending_user_input: HashMap>, pending_dynamic_tools: HashMap>, pending_input: Vec, + 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 { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 97719f1049..b3b3cc87e6 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -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::::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::>(); - 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::::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::>(); + self.record_conversation_items(turn_context, &pending_response_items) + .await; + } + } + async fn handle_task_abort(self: &Arc, 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, + 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, + turn_context: &TurnContext, + reason: TurnAbortReason, + ) { + self.emit_turn_aborted_inner(turn_context, reason, false) + .await; + } + + async fn emit_turn_aborted_inner( + self: &Arc, + 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; } } diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1f20f5dfdf..49f4bd2b88 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -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(mode_name: &str, build_mode: F) -> anyhow::Result<()> where F: FnOnce(String) -> CollaborationMode, diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index cb076264dd..38f9b62678 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -41,6 +41,8 @@ pub struct RequestUserInputAnswer { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestUserInputResponse { pub answers: HashMap, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub interrupted: bool, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 79b1229800..f4768de2ec 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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 { + 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::>(); + 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::>(); - 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) { - let event = rx.try_recv().expect("expected interrupt AppEvent"); + fn expect_partial_interrupt_submission( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + 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]