Keep the session online after a generic fatal engine error

Refs #6561 (U02-01; the audit tracker stays open).

apply_engine_error_to_app set offline_mode for every non-recoverable error.
Three engine paths emit a fatal authentication error without starting or
completing a model turn (a finished background shell that cannot resolve its
route, a goal continuation that cannot resolve its route, and a child
completion that cannot resolve the resume route), so no TurnComplete follows
and neither lifecycle clearer runs. The flag then queued every later submit and
restored every explicitly selected queued message instead of sending it, so the
routing refusal blocked the next healthy attempt from even reaching route
validation.

Only the generic setter is removed. The environment-only authentication branch
still sets offline_mode, reopens provider setup and posts its targeted notice;
explicit offline onboarding, provider setup completion and failed-switch
rollback are untouched. The fatal error still finalizes the same cells, ends
loading, records failed status for an active turn and leaves the categorized
error in history. A still-broken route can fail again and the existing recovery
path keeps the unsent message; there is no automatic retry or provider rotation.

Two existing expectations flip and one existing test is renamed
(non_recoverable_engine_error_enters_offline_mode to ..._stays_online); no new
test was added. The comments that described the old behaviour on the offline
field, the error projection, the renamed test and fatal_auth are corrected.

Verified locally (macOS; no hosted CI): frozen tui lib nextest batch, 426 run,
426 passed, 0 failed, 14876 skipped, shared with the MCP change. Every named
existing test matched: non_recoverable_engine_error_stays_online,
keyless_engine_error_stays_visible_after_a_config_ack,
env_only_auth_failure_reopens_provider_onboarding,
recoverable_engine_error_does_not_enter_offline_mode,
offline_explore_selects_no_provider_and_writes_no_credential, the credential_
rejected_turn_ pair, credential_rejection_after_output_keeps_the_composer_empty,
recoverable_stream_error_keeps_active_turn_for_pending_approval,
provider_switch_auth_error_restores_previous_provider_and_model,
real_engine_client_preflight_failure_leaves_dispatch_state_atomic,
immediate_submit_closed_mailbox_restores_composer_and_skill and
submit_disposition_scenario (1 each), plus 8 engine_error tests.

Not verified: the trap itself was not reproduced in the running product. A TUI
run against a local daemon that answers every chat request with 401 shows the
in-turn authentication error and the existing resend note, which does not take
the no-TurnComplete path, so it cannot tell the old and new behaviour apart. The
three fatal paths above are known from source, not from a live run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-25 05:58:15 -07:00
1 parent fa659b9858
commit 7d84d0be6b
4 files changed
+10 -17

No files matched your search

+1 -1
View File
@@ -132,7 +132,7 @@ impl ErrorEnvelope {
)
}
/// Authentication failure — fatal and blocks the session.
/// Authentication failure — fatal for the attempted operation.
#[must_use]
pub fn fatal_auth(message: impl Into<String>) -> Self {
Self::new(
+1 -1
View File
@@ -1652,7 +1652,7 @@ pub struct App {
/// each TurnStarted; background suggestion tasks capture the token and
/// discard their result if the token no longer matches.
pub prompt_suggestion_gen: std::sync::atomic::AtomicU64,
/// Degraded connectivity mode; new user inputs are queued for later retry.
/// Queue new submissions while browsing offline or recovering provider setup.
pub offline_mode: bool,
/// Whether an `EngineEvent::Error` has already been posted for the
/// current turn. Suppresses the redundant "Turn failed:" status line
+2 -8
View File
@@ -178,11 +178,8 @@ pub(crate) fn apply_picker_session_rename_to_active_app(
/// Translate an `EngineEvent::Error` into UI state updates.
///
/// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides
/// whether the session flips into offline mode: stream stalls, chunk
/// timeouts, transient network errors, and rate-limit/server hiccups arrive
/// recoverable and must NOT flip into offline. Hard failures (auth, billing,
/// invalid request) arrive non-recoverable; those flip offline so subsequent
/// messages get queued instead of silently lost mid-flight.
/// whether an active turn stays live. Only an environment-only authentication
/// failure enables offline queueing here while reopening provider setup.
///
/// `severity` drives transcript color: red for `Error`/`Critical`, amber for
/// `Warning`, dim for `Info`.
@@ -324,9 +321,6 @@ pub(crate) fn apply_engine_error_to_app(
);
return;
}
if !recoverable {
app.offline_mode = true;
}
// Error is already in the transcript as HistoryCell::Error above;
// don't emit a redundant status_message that would become a sticky
// toast in the footer — that duplicates the transcript entry.
+6 -7
View File
@@ -28488,11 +28488,10 @@ async fn agent_complete_clears_child_pending() {
assert_eq!(rows.len(), 1);
}
/// Hard failures (auth, billing, malformed request) DO need to flip offline
/// mode so subsequent typed messages get queued instead of silently lost
/// against a broken upstream.
/// Hard failures leave the session online for a retry; an error without
/// TurnComplete must not strand subsequent messages offline.
#[test]
fn non_recoverable_engine_error_enters_offline_mode() {
fn non_recoverable_engine_error_stays_online() {
use crate::error_taxonomy::ErrorEnvelope;
let mut app = create_test_app();
assert!(!app.offline_mode);
@@ -28506,8 +28505,8 @@ fn non_recoverable_engine_error_enters_offline_mode() {
);
assert!(
app.offline_mode,
"non-recoverable error must enter offline mode"
!app.offline_mode,
"non-recoverable error must keep the session online for a retry"
);
assert!(!app.is_loading);
assert_eq!(app.runtime_turn_status.as_deref(), Some("failed"));
@@ -28643,7 +28642,7 @@ async fn keyless_engine_error_stays_visible_after_a_config_ack() {
}
}
drop(events);
assert!(app.offline_mode);
assert!(!app.offline_mode);
assert!(!app.is_loading);
for (width, height) in [(80, 24), (140, 40)] {
let frame = render_test_app(&mut app, &config, width, height);