From 641e2e362ca781b8fe350c396441d49e34dab35e Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 13 Nov 2026 22:52:29 +0000 Subject: [PATCH] Ignore stale workspace headline requests and archived messages (#61411) ## Why Workspace headline request counters restart when the chat widget is replaced, allowing a response for the previous widget to match a new pending request. Archived headlines can also be selected for the status line. ## What changed - Use UUIDs for workspace headline requests and reject refresh events and responses that do not match the current widget's pending request. - Skip archived messages when selecting the first non-empty workspace headline. ## Testing Add a regression test verifying that a replacement widget rejects the previous widget's response while retaining its own pending request. Extend headline selection coverage to include an archived headline before the active one. GitOrigin-RevId: 44b98ab8c6cb70b7cf8e8b9341f27c99c1e1490a --- codex-rs/tui/src/app/background_requests.rs | 5 +++- codex-rs/tui/src/app_event.rs | 4 +-- codex-rs/tui/src/chatwidget.rs | 4 +-- codex-rs/tui/src/chatwidget/constructor.rs | 1 - .../tui/src/chatwidget/status_surfaces.rs | 13 ++++----- .../src/chatwidget/tests/status_and_layout.rs | 27 ++++++++++++++++--- codex-rs/tui/src/workspace_messages.rs | 2 +- codex-rs/tui/src/workspace_messages_tests.rs | 7 +++++ 8 files changed, 45 insertions(+), 18 deletions(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index ba9643f87f..bab924254d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -220,8 +220,11 @@ impl App { pub(super) fn refresh_status_line_workspace_headline( &mut self, app_server: &AppServerSession, - request_id: u64, + request_id: Uuid, ) { + if !self.chat_widget.is_pending_workspace_headline(request_id) { + return; + } let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1da9ac4e09..0e95e08683 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -978,7 +978,7 @@ pub(crate) enum AppEvent { /// Fetch workspace messages for the status-line headline item. RefreshStatusLineWorkspaceHeadline { - request_id: u64, + request_id: Uuid, }, /// Commit settled asynchronous usage output after active-output barriers clear. @@ -1843,7 +1843,7 @@ pub(crate) enum AppEvent { }, /// Async update of the workspace notification headline for status line rendering. StatusLineWorkspaceHeadlineUpdated { - request_id: u64, + request_id: Uuid, result: Result, }, /// Apply a user-confirmed status-line item ordering/selection. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ba0ed1b247..e298aaaada 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -794,9 +794,7 @@ pub(crate) struct ChatWidget { // Cached workspace notification headline for the status line. status_line_workspace_headline: Option, // Request ID for the async workspace headline fetch currently in flight. - status_line_workspace_headline_pending_request_id: Option, - // Request ID to assign to the next workspace headline fetch. - next_status_line_workspace_headline_request_id: u64, + status_line_workspace_headline_pending_request_id: Option, // Last time a workspace headline fetch was requested. status_line_workspace_headline_last_requested_at: Option, // Set after the backend reports the workspace-message feature gate is disabled. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 8db981c03f..8abb0ed8c9 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -299,7 +299,6 @@ impl ChatWidget { status_line_git_summary_lookup_complete: false, status_line_workspace_headline: None, status_line_workspace_headline_pending_request_id: None, - next_status_line_workspace_headline_request_id: 0, status_line_workspace_headline_last_requested_at: None, status_line_workspace_messages_disabled: false, thread_usage: thread_usage::ThreadUsageState::default(), diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index b4a10bace1..ee70366726 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -665,10 +665,7 @@ impl ChatWidget { if !self.status_line_workspace_headline_should_fetch(now) { return; } - let request_id = self.next_status_line_workspace_headline_request_id; - self.next_status_line_workspace_headline_request_id = self - .next_status_line_workspace_headline_request_id - .wrapping_add(/*rhs*/ 1); + let request_id = uuid::Uuid::new_v4(); self.status_line_workspace_headline_pending_request_id = Some(request_id); self.status_line_workspace_headline_last_requested_at = Some(now); self.app_event_tx @@ -704,12 +701,16 @@ impl ChatWidget { } } + pub(crate) fn is_pending_workspace_headline(&self, request_id: uuid::Uuid) -> bool { + self.status_line_workspace_headline_pending_request_id == Some(request_id) + } + pub(crate) fn set_status_line_workspace_headline( &mut self, - request_id: u64, + request_id: uuid::Uuid, result: Result, ) -> bool { - if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + if !self.is_pending_workspace_headline(request_id) { return false; } self.status_line_workspace_headline_pending_request_id = None; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index e91093a55f..354091504f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -49,7 +49,7 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { fn take_workspace_headline_request_id( rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> u64 { +) -> uuid::Uuid { match rx.try_recv() { Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, event => panic!("expected workspace headline refresh, got {event:?}"), @@ -4010,7 +4010,7 @@ async fn workspace_headline_update_applies_feature_disabled_result() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old headline".to_string()); - let request_id = 3; + let request_id = uuid::Uuid::new_v4(); chat.status_line_workspace_headline_pending_request_id = Some(request_id); assert!(chat.set_status_line_workspace_headline( @@ -4026,7 +4026,7 @@ async fn workspace_headline_update_applies_feature_disabled_result() { async fn workspace_headline_update_applies_available_headline() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); - let request_id = 4; + let request_id = uuid::Uuid::new_v4(); chat.status_line_workspace_headline_pending_request_id = Some(request_id); assert!(chat.set_status_line_workspace_headline( @@ -4050,7 +4050,7 @@ async fn account_update_clears_workspace_headline_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); - chat.status_line_workspace_headline_pending_request_id = Some(5); + chat.status_line_workspace_headline_pending_request_id = Some(uuid::Uuid::new_v4()); chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); chat.status_line_workspace_messages_disabled = true; @@ -4087,6 +4087,25 @@ async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() ); } +#[tokio::test] +async fn replacement_widget_rejects_the_previous_widgets_workspace_headline() { + let request_headline = async || { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".into()]); + chat.has_codex_backend_auth = true; + chat.refresh_status_line(); + (chat, take_workspace_headline_request_id(&mut rx)) + }; + let (_, stale) = request_headline().await; + let (mut replacement, current) = request_headline().await; + + assert!(!replacement.set_status_line_workspace_headline( + stale, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert!(replacement.is_pending_workspace_headline(current)); +} + #[tokio::test] async fn account_update_discards_stale_workspace_headline_results() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs index 4f5695f80c..f72953fd76 100644 --- a/codex-rs/tui/src/workspace_messages.rs +++ b/codex-rs/tui/src/workspace_messages.rs @@ -18,7 +18,7 @@ pub(crate) fn workspace_headline_from_response( } WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { - (message.message_type == WorkspaceMessageType::Headline) + (message.archived_at.is_none() && message.message_type == WorkspaceMessageType::Headline) .then(|| message.message_body.trim().to_string()) .filter(|headline| !headline.is_empty()) })) diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs index 962d2ceba1..b3e1c88fc2 100644 --- a/codex-rs/tui/src/workspace_messages_tests.rs +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -7,6 +7,13 @@ fn workspace_headline_from_response_uses_first_non_empty_headline() { let response = GetWorkspaceMessagesResponse { feature_enabled: true, messages: vec![ + WorkspaceMessage { + message_id: "archived-headline".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: "Previous announcement".to_string(), + created_at: None, + archived_at: Some(1_700_000_000), + }, WorkspaceMessage { message_id: "announcement-id".to_string(), message_type: WorkspaceMessageType::Announcement,