Compare commits

...

10 Commits

Author SHA1 Message Date
canvrno-oai
245b204b23 Let lifecycle notifications update active turn cache 2026-05-26 15:08:05 -07:00
canvrno-oai
a14be61351 Merge branch 'main' into canvrno/review_mode_steer_esc 2026-05-26 14:40:18 -07:00
canvrno-oai
60f6b8b071 Set active turn for review threads 2026-05-26 13:00:59 -07:00
canvrno-oai
279a066780 Merge branch 'main' into canvrno/review_mode_steer_esc 2026-05-26 12:11:49 -07:00
canvrno-oai
89984ddfbe Merge branch 'main' into canvrno/review_mode_steer_esc 2026-05-21 11:38:13 -07:00
canvrno-oai
244a0284f9 retry turn interrupts after active-turn mismatch - Ctrl + C case 2026-05-18 12:02:10 -07:00
canvrno-oai
1bd86b00d3 snap metadata 2026-05-18 11:57:32 -07:00
canvrno-oai
60b26e4062 Updated snapshot with new text 2026-05-18 11:57:32 -07:00
canvrno-oai
5cecc587af snapshot 2026-05-18 11:57:32 -07:00
canvrno-oai
a4818421d7 reject Esc steer submission during /review 2026-05-18 11:57:32 -07:00
7 changed files with 122 additions and 11 deletions

View File

@@ -652,6 +652,25 @@ fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRa
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
}
fn active_turn_interrupt_race(error: &TypedRequestError) -> Option<String> {
let TypedRequestError::Server { method, source } = error else {
return None;
};
if method != "turn/interrupt" {
return None;
}
let mismatch_prefix = "expected active turn id ";
let mismatch_separator = " but found ";
Some(
source
.message
.strip_prefix(mismatch_prefix)?
.split_once(mismatch_separator)?
.1
.to_string(),
)
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,

View File

@@ -4404,6 +4404,23 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
);
}
#[test]
fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() {
let error = TypedRequestError::Server {
method: "turn/interrupt".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: "expected active turn id turn-expected but found turn-actual".to_string(),
data: None,
},
};
assert_eq!(
active_turn_interrupt_race(&error),
Some("turn-actual".to_string())
);
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;

View File

@@ -499,9 +499,39 @@ impl App {
match op {
AppCommand::Interrupt => {
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
app_server.turn_interrupt(thread_id, turn_id).await?;
let mut interrupt_turn_id = turn_id;
for retried_after_turn_mismatch in [false, true] {
match app_server
.turn_interrupt(thread_id, interrupt_turn_id.clone())
.await
{
Ok(()) => return Ok(true),
Err(error) if !retried_after_turn_mismatch => {
let Some(actual_turn_id) = active_turn_interrupt_race(&error)
else {
return Err(error).wrap_err("turn/interrupt failed in TUI");
};
if actual_turn_id == interrupt_turn_id {
return Err(error).wrap_err("turn/interrupt failed in TUI");
}
// Review flows can swap the active turn before the TUI processes
// the corresponding notification. Retry once with the
// server-reported turn id so Ctrl+C/Esc do not fatally exit on that
// stale cache, but let lifecycle notifications own the cached
// active turn id.
interrupt_turn_id = actual_turn_id;
}
Err(error) => {
return Err(error).wrap_err("turn/interrupt failed in TUI");
}
}
}
unreachable!("interrupt retry loop should return");
} else {
app_server.startup_interrupt(thread_id).await?;
app_server
.startup_interrupt(thread_id)
.await
.wrap_err("turn/interrupt failed in TUI")?;
}
Ok(true)
}
@@ -651,7 +681,12 @@ impl App {
Ok(true)
}
AppCommand::Review { target } => {
app_server.review_start(thread_id, target.clone()).await?;
let response = app_server.review_start(thread_id, target.clone()).await?;
let review_thread_id = ThreadId::from_string(&response.review_thread_id)
.wrap_err("review/start returned invalid review thread id")?;
let store = Arc::clone(&self.ensure_thread_channel(review_thread_id).store);
let mut store = store.lock().await;
store.active_turn_id = Some(response.turn.id);
Ok(true)
}
AppCommand::CleanBackgroundTerminals => {

View File

@@ -700,7 +700,7 @@ impl AppServerSession {
&mut self,
thread_id: ThreadId,
turn_id: String,
) -> Result<()> {
) -> std::result::Result<(), TypedRequestError> {
let request_id = self.next_request_id();
let _: TurnInterruptResponse = self
.client
@@ -711,12 +711,14 @@ impl AppServerSession {
turn_id,
},
})
.await
.wrap_err("turn/interrupt failed in TUI")?;
.await?;
Ok(())
}
pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> {
pub(crate) async fn startup_interrupt(
&mut self,
thread_id: ThreadId,
) -> std::result::Result<(), TypedRequestError> {
self.turn_interrupt(thread_id, String::new()).await
}

View File

@@ -112,6 +112,21 @@ impl ChatWidget {
return;
}
const REVIEW_ESC_STEER_UNAVAILABLE_MESSAGE: &str = "Steer messages aren't supported during /review. Press Ctrl+C now to cancel the review.";
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.review.is_review_mode
&& (!self.input_queue.pending_steers.is_empty()
|| !self.input_queue.rejected_steers_queue.is_empty())
&& self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& !self.should_handle_vim_insert_escape(key_event)
{
self.add_warning_message(REVIEW_ESC_STEER_UNAVAILABLE_MESSAGE.to_string());
return;
}
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !self.input_queue.pending_steers.is_empty()

View File

@@ -1,7 +1,7 @@
---
source: tui/src/chatwidget/tests/review_mode.rs
expression: lines_to_single_string(last)
assertion_line: 307
expression: last
---
⚠ Steer messages aren't supported during /review. Send your message after the
review finishes, or press Ctrl+C now to cancel the review.
⚠ Steer messages aren't supported during /review. Press Ctrl+C now to cancel the
review.

View File

@@ -284,6 +284,29 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
}
}
#[tokio::test]
async fn esc_with_review_queued_steers_shows_warning_and_does_not_interrupt() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
handle_entered_review_mode(&mut chat, "feature branch");
let _ = drain_insert_history(&mut rx);
chat.input_queue
.pending_steers
.push_back(pending_steer("review follow-up"));
chat.refresh_pending_input_preview();
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_no_submit_op(&mut op_rx);
let cells = drain_insert_history(&mut rx);
let last = lines_to_single_string(cells.last().expect("review warning"));
assert_chatwidget_snapshot!("review_submission_warning_snapshot", last);
}
#[tokio::test]
async fn live_agent_message_renders_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;