mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
tui/core: partially submit committed request_user_input answers on interrupt
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user