fix: Prevent /review crash when entering Esc on steer message (#22879)

This changes the `/review` escape path so `Esc` no longer behaves like
the normal queued-follow-up interrupt flow while a review is running.
Steering is not currently supported in `/review` mode, without this
change users are able to attempt a steer but it leads to a crash (see
#22815). If the user has already tried to send additional guidance
during `/review`, the TUI now keeps the review running and shows a
warning that steer messages are not supported in that mode, while still
pointing users to `Ctrl+C` if they actually want to cancel. It also adds
regression coverage for the review-specific warning behavior. When users
do cancel with Ctrl+C during /review, the TUI now tolerates the
active-turn race that can happen during review handoff, and any queued
steer messages are restored to the composer instead of being discarded.

- Special-case `Esc` during an active `/review` when follow-up steer
input is pending or has already been deferred.
- Show a clear warning instead of interrupting the running review.
- Make the Ctrl+C cancel path during /review resilient to active-turn
races, while preserving any queued steer text by restoring it to the
composer.
- Add review-mode test coverage for the warning path.

## Testing

1. Start a `/review` with a diff large enough that the review stays
active for more than a few seconds.

2. While the review is still running, type a follow-up / steer message,
submit it, and then press `Esc`.
   Before: `Esc` causes the TUI to close abruptly.  
After: the review keeps running and the transcript shows a warning that
steer messages are not supported during `/review`, with guidance to use
`Ctrl+C` if you want to cancel.

3. Press `Ctrl+C` if you actually want to stop the review.  
Before: (after restarting the test since Pt. 2 crashed) this is the
intentional cancellation path.
After: this remains the intentional cancellation path, and any queued
follow-up steer text is restored to the composer instead of being lost.
   
## Note:
`/review` mode explicitly does not support steering at this time (as
noted in `turn_processer.rs`, if we want to explore that in the future
this code will need to be modified). This change keeps unsupported steer
attempts from crashing the TUI and preserves queued follow-up text if
the user cancels with Ctrl+C.
This commit is contained in:
canvrno-oai
2026-06-09 09:41:58 -07:00
committed by GitHub
Unverified
parent 6a9a49b334
commit 8a299fb704
7 changed files with 121 additions and 11 deletions
+17
View File
@@ -4709,6 +4709,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;
+38 -3
View File
@@ -500,9 +500,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)
}
@@ -652,7 +682,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 => {