Merge pull request #6452 from Hmbown/fix/turn-stop-reason-in-transcript

fix(tui): keep an Engine-stopped turn's reason in the transcript
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-24 11:37:13 -07:00
commit 6d148e630d
2 files changed
+67 -17

No files matched your search

+26 -17
View File
@@ -1307,6 +1307,31 @@ async fn dispatch_launch_composer_submit(
/// from slash-menu selection onward is the shared `submit_decided_composer_input`
/// tail the keyboard Enter arm also uses, so the two surfaces cannot drift.
#[allow(clippy::too_many_arguments)]
/// Show why a turn ended without success. The composer status line always
/// names it; a turn the Engine stopped itself (wall-clock or step budget, no
/// progress, an incomplete response) posts no error event, so its reason also
/// goes into the transcript — a footer line alone is replaced by the next
/// notice, and the session then reads as hung. When an error event already
/// put the message in the transcript, nothing is repeated.
pub(super) fn present_turn_failure(
app: &mut App,
status: crate::core::events::TurnOutcomeStatus,
error: Option<&str>,
) {
let Some(error) = error else { return };
if app.turn_error_posted {
return;
}
let notice = format!("{}: {error}", app.tr(MessageId::NotificationTurnFailed));
if matches!(status, crate::core::events::TurnOutcomeStatus::Failed) {
app.add_message(HistoryCell::Error {
message: notice.clone(),
severity: crate::error_taxonomy::ErrorSeverity::Warning,
});
}
app.set_sticky_status(notice, StatusToastLevel::Error, None);
}
async fn dispatch_session_composer_submit(
terminal: &mut AppTerminal,
app: &mut App,
@@ -2754,23 +2779,7 @@ pub(crate) async fn run_event_loop(
recorded_at: Instant::now(),
});
app.retire_action_notices(None);
if let Some(error) = error.as_deref() {
// Only show "Turn failed:" in the composer status
// area when an EngineEvent::Error has NOT already
// posted the same message into the transcript.
// Otherwise the error appears twice: once in a
// HistoryCell and again as a redundant status line.
if !app.turn_error_posted {
app.set_sticky_status(
format!(
"{}: {error}",
app.tr(MessageId::NotificationTurnFailed)
),
StatusToastLevel::Error,
None,
);
}
}
present_turn_failure(app, status, error.as_deref());
// Update session cost, and record what the total does
// *not* cover so `/cost` can stay honest about it.
+41
View File
@@ -16902,6 +16902,47 @@ fn local_cancel_marks_late_stream_events_for_suppression() {
));
}
/// A turn the Engine stopped itself (here its per-turn wall-clock budget)
/// posts no error event. Its reason must stay in the transcript, not only the
/// footer, or a later notice replaces it and the session looks hung.
#[test]
fn an_engine_stopped_turn_keeps_its_reason_in_the_transcript() {
let mut app = create_test_app();
let reason = "Per-turn wall-clock budget exhausted after 3600s (limit: 3600s). Send another message to continue.";
super::event_loop::present_turn_failure(
&mut app,
crate::core::events::TurnOutcomeStatus::Failed,
Some(reason),
);
assert!(
app.history.iter().any(|cell| matches!(
cell,
HistoryCell::Error { message, .. } if message.contains(reason)
)),
"the stop reason is in the transcript"
);
assert!(
app.sticky_status
.as_ref()
.is_some_and(|toast| toast.text.contains(reason))
);
// An error event already posted it: never repeat it in the transcript.
let mut posted = create_test_app();
posted.turn_error_posted = true;
super::event_loop::present_turn_failure(
&mut posted,
crate::core::events::TurnOutcomeStatus::Failed,
Some(reason),
);
assert!(
!posted
.history
.iter()
.any(|cell| matches!(cell, HistoryCell::Error { .. }))
);
}
#[test]
fn turn_started_route_is_captured_before_cancel_suppression() {
let mut app = create_test_app();