Suppress stale cloud stream disconnects after reopening a thread (#60327)

## Why

A queued cloud stream disconnect can outlive a successful reopen of the same conversation, causing an obsolete reconnect error to reach the TUI.

## What changed

Represent cloud reply stream losses with `AppServerEvent::ThreadStreamDisconnected` and filter queued losses superseded by a successful attachment to the same thread. Preserve losses for other threads and any newer disconnects; failed reopen attempts do not supersede pending losses.

Continue displaying current stream disconnects through the TUI's error notification handler.

## Testing

Add regression coverage for successful and failed reopens, disconnect ordering across threads, and TUI handling of disconnect events from the active and foreign backends.

GitOrigin-RevId: 640332b817970717fc816ab5a32bf658c01233bf
This commit is contained in:
Benjamin Carlsson
2026-11-09 02:31:46 +00:00
committed by copyberry
parent b9796386e5
commit ffdf2daddb
8 changed files with 174 additions and 25 deletions
@@ -156,6 +156,13 @@ impl App {
.await;
self.repaint_agents_overview();
}
AppServerEvent::ThreadStreamDisconnected(error) => {
self.handle_server_notification_event(
app_server_client,
ServerNotification::Error(*error),
)
.await;
}
AppServerEvent::Disconnected { message } => {
// The request result distinguishes a refused start from a lost reply.
if self.pending_startup_thread_start
@@ -11,26 +11,32 @@ use pretty_assertions::assert_eq;
#[tokio::test]
async fn foreign_backend_events_leave_thread_cache_and_connection_untouched() -> Result<()> {
let (mut app, mut events, _) = make_test_app_with_channels().await;
let (server, requests, proxy) =
let (mut server, requests, proxy) =
session_lifecycle_requests::start_recording_remote_app_server(&app.config).await?;
let id = ThreadId::new();
app.primary_thread_id = Some(id);
app.active_thread_id = Some(id);
set_test_initial_prompt(&mut app, String::new());
app.chat_widget
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
app.chat_widget
.restore_user_message_to_composer("keep draft".into());
app.ensure_thread_channel(id);
app.activate_thread_channel(id).await;
while events.try_recv().is_ok() {}
requests.lock().unwrap().clear();
let input = app.chat_widget.capture_thread_input_state();
let hard_stop_generation = app.rate_limit_hard_stop_generation;
let foreign = BackendId::new();
let request = exec_approval_request(id, "foreign-turn", "command", /*approval_id*/ None);
let loss: codex_app_server_protocol::ErrorNotification =
serde_json::from_value(serde_json::json!({
"threadId": id.to_string(), "turnId": "", "willRetry": false,
"error": { "message": "Cloud stream ended. Reopen the conversation to reconnect." }
}))?;
for event in [
AppServerEvent::ServerNotification(Box::new(turn_started_notification(id, "foreign-turn"))),
AppServerEvent::ServerRequest(Box::new(request.clone())),
AppServerEvent::ThreadStreamDisconnected(Box::new(loss.clone())),
AppServerEvent::Lagged { skipped: 1 },
AppServerEvent::Disconnected {
message: "foreign disconnected".into(),
@@ -107,6 +113,21 @@ async fn foreign_backend_events_leave_thread_cache_and_connection_untouched() ->
.active_turn_id(),
Some("owned-turn")
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.drain_active_thread_events(&mut tui).await?;
app.handle_backend_event(
&server,
AppServerSessionEvent {
backend_id: server.backend_id(),
event: AppServerEvent::ThreadStreamDisconnected(Box::new(loss)),
},
)
.await;
app.drain_active_thread_events(&mut tui).await?;
let history =
super::active_reconnect::drain_history(&mut app, &mut tui, &mut server, &mut events)
.await?;
insta::assert_snapshot!(history, @"■ Cloud stream ended. Reopen the conversation to reconnect.");
server.shutdown().await?;
proxy.await??;
Ok(())
+1
View File
@@ -256,6 +256,7 @@ stream_max_retries = 0
return Ok::<_, color_eyre::eyre::Report>(());
}
}
codex_app_server_client::AppServerEvent::ThreadStreamDisconnected(_) => {}
codex_app_server_client::AppServerEvent::Lagged { .. }
| codex_app_server_client::AppServerEvent::Disconnected { .. } => {
panic!("lost app-server events during the tool round trip");
@@ -794,7 +794,8 @@ async fn run_onboarding_screen_inner(
AppServerEvent::Disconnected { message } => {
return Err(color_eyre::eyre::eyre!(message));
}
AppServerEvent::Lagged { .. }
AppServerEvent::ThreadStreamDisconnected(_)
| AppServerEvent::Lagged { .. }
| AppServerEvent::ServerRequest(_) => {}
}
}