mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
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:
committed by
copyberry
parent
b9796386e5
commit
ffdf2daddb
@@ -44,19 +44,62 @@ async fn failed_replacement_keeps_old_thread_until_new_attachment_succeeds() ->
|
||||
|
||||
let _ = close.send(());
|
||||
assert!(replies.admit_turn("new", &output).is_err());
|
||||
let AppServerEvent::ServerNotification(notification) = notifications.try_recv()? else {
|
||||
let AppServerEvent::ThreadStreamDisconnected(notification) = notifications.try_recv()? else {
|
||||
panic!("expected a disconnect notification");
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(notification)?,
|
||||
serde_json::to_value(cloud_error(
|
||||
serde_json::to_value(protocol::ServerNotification::Error(*notification))?,
|
||||
serde_json::to_value(protocol::ServerNotification::Error(cloud_error(
|
||||
"new",
|
||||
"Cloud stream ended. Reopen the conversation to reconnect."
|
||||
))?
|
||||
)))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_reopen_supersedes_only_earlier_losses_for_that_thread() -> Result<()> {
|
||||
let (mut client, _commands, output) = CloudClient::channel(/*capacity*/ 1);
|
||||
let mut replies = Replies {
|
||||
stream_losses: client.stream_losses.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for id in ["reopened", "different", "reopened"] {
|
||||
let (sender, stream) = futures::channel::mpsc::unbounded();
|
||||
replies
|
||||
.replace(id, async { Ok(stream.boxed()) }, &output)
|
||||
.await?;
|
||||
drop(sender);
|
||||
replies.forward_next(&output).await;
|
||||
}
|
||||
assert!(
|
||||
replies
|
||||
.replace("reopened", async { Ok(stream::empty().boxed()) }, &output)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// The first EOF was followed by a successful reopen. A different thread's
|
||||
// EOF and the reopened thread's newer EOF must still be delivered, in order.
|
||||
drop(output);
|
||||
for expected in ["different", "reopened"] {
|
||||
let Some(AppServerEvent::ThreadStreamDisconnected(error)) = client.next_event().await
|
||||
else {
|
||||
panic!("expected a thread stream disconnect");
|
||||
};
|
||||
assert_eq!(
|
||||
*error,
|
||||
cloud_error(
|
||||
expected,
|
||||
"Cloud stream ended. Reopen the conversation to reconnect."
|
||||
)
|
||||
);
|
||||
}
|
||||
assert!(client.next_event().await.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attachment_status_follows_its_server_notifications() -> Result<()> {
|
||||
let (output, _notifications) = mpsc::unbounded_channel();
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::AppServerClient;
|
||||
use crate::AppServerEvent;
|
||||
use crate::CloudClient;
|
||||
use crate::CloudOperation;
|
||||
use crate::cloud::CloudStreamLosses;
|
||||
use codex_app_server_protocol as protocol;
|
||||
use codex_cloud_client as cloud;
|
||||
use color_eyre::eyre::Result;
|
||||
@@ -41,6 +42,7 @@ struct Attachment {
|
||||
#[derive(Default)]
|
||||
struct Replies {
|
||||
active: Option<Attachment>,
|
||||
stream_losses: CloudStreamLosses,
|
||||
// Keep the last observed status through EOF until this conversation is unloaded.
|
||||
latest_status: Option<(String, cloud::ThreadStatus)>,
|
||||
}
|
||||
@@ -53,14 +55,13 @@ impl Replies {
|
||||
match active.stream.next().await {
|
||||
Some(Ok(event)) => forward(&active.thread_id, event, output, &mut self.latest_status),
|
||||
Some(Err(_)) | None => {
|
||||
send_notification(
|
||||
output,
|
||||
cloud_error(
|
||||
&active.thread_id,
|
||||
"Cloud stream ended. Reopen the conversation to reconnect.",
|
||||
),
|
||||
let error = cloud_error(
|
||||
&active.thread_id,
|
||||
"Cloud stream ended. Reopen the conversation to reconnect.",
|
||||
);
|
||||
self.active = None;
|
||||
self.stream_losses.record(&error.thread_id);
|
||||
let _ = output.send(AppServerEvent::ThreadStreamDisconnected(Box::new(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,14 +97,15 @@ impl Replies {
|
||||
Some(Some(Ok(event))) => Some(event),
|
||||
None => None,
|
||||
};
|
||||
self.latest_status = None;
|
||||
if let Some(event) = first_event {
|
||||
forward(id, event, output, &mut self.latest_status);
|
||||
}
|
||||
self.active = Some(Attachment {
|
||||
thread_id: id.to_owned(),
|
||||
stream,
|
||||
});
|
||||
self.stream_losses.supersede(id);
|
||||
self.latest_status = None;
|
||||
if let Some(event) = first_event {
|
||||
forward(id, event, output, &mut self.latest_status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -155,8 +157,12 @@ fn start_worker(client: cloud::Client, account: protocol::GetAccountResponse) ->
|
||||
loaded: None,
|
||||
warned_partial_history: None,
|
||||
};
|
||||
let mut replies = Replies {
|
||||
stream_losses: app_client.stream_losses.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
run_worker(commands, &mut requests, &mut Replies::default(), &output).await;
|
||||
run_worker(commands, &mut requests, &mut replies, &output).await;
|
||||
});
|
||||
AppServerClient::Cloud(app_client)
|
||||
}
|
||||
@@ -298,7 +304,10 @@ fn forward(
|
||||
cloud::Event::Transcript(event) => match events::transcript(event.thread_id, event.update) {
|
||||
Ok(Some(notification)) => notification,
|
||||
Ok(None) => return,
|
||||
Err(_) => cloud_error(id, "The cloud sent an unsupported transcript event."),
|
||||
Err(_) => protocol::ServerNotification::Error(cloud_error(
|
||||
id,
|
||||
"The cloud sent an unsupported transcript event.",
|
||||
)),
|
||||
},
|
||||
cloud::Event::ServerRequest(_) => protocol::ServerNotification::Warning(protocol::WarningNotification {
|
||||
thread_id: Some(id.to_owned()),
|
||||
@@ -316,8 +325,8 @@ fn send_notification(
|
||||
let _ = output.send(AppServerEvent::ServerNotification(Box::new(notification)));
|
||||
}
|
||||
|
||||
fn cloud_error(id: &str, message: &str) -> protocol::ServerNotification {
|
||||
protocol::ServerNotification::Error(protocol::ErrorNotification {
|
||||
fn cloud_error(id: &str, message: &str) -> protocol::ErrorNotification {
|
||||
protocol::ErrorNotification {
|
||||
thread_id: id.to_owned(),
|
||||
turn_id: String::new(),
|
||||
will_retry: false,
|
||||
@@ -327,7 +336,7 @@ fn cloud_error(id: &str, message: &str) -> protocol::ServerNotification {
|
||||
codex_error_info: None,
|
||||
misalignment: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user