From 5ca2007abe0f514f62c14e333075fb1faf968cc4 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 13 Nov 2026 22:52:29 +0000 Subject: [PATCH] Expose thread costs in statusline state and reject stale usage reads (#61410) ## Why A replacement chat widget can receive a previous widget's usage result for the same thread. Statusline state also lacks the estimated costs available in the built-in status line. ## What changed - Add optional `thread.usage.cost.total_cost_usd` and `total_credits` fields, using the built-in status line's plan-specific values. Convert micro-units to ordinary units and omit unavailable or negative values. - Use UUIDs for thread usage requests and verify that each request is still pending before fetching usage or accepting its result. ## Testing Add regression coverage for rejecting a previous widget's usage result and assertions for serialized costs across supported enterprise plans. GitOrigin-RevId: 91c3f7cead0249b673979d17cc108ceae1088df2 --- codex-rs/tui/src/app/background_requests.rs | 8 +++- codex-rs/tui/src/app/history_ui.rs | 2 +- codex-rs/tui/src/app/tests/thread_usage.rs | 4 +- codex-rs/tui/src/app_event.rs | 4 +- .../tui/src/chatwidget/status_surfaces.rs | 8 +--- .../src/chatwidget/statusline_state/usage.rs | 17 ++++++++ codex-rs/tui/src/chatwidget/thread_usage.rs | 39 +++++++++++-------- .../tui/src/chatwidget/thread_usage_tests.rs | 35 ++++++++++++++++- 8 files changed, 88 insertions(+), 29 deletions(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 1eba13498f..ba9643f87f 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -161,8 +161,14 @@ impl App { &mut self, app_server: &AppServerSession, thread_id: ThreadId, - request_id: u64, + request_id: Uuid, ) { + if !self + .chat_widget + .is_pending_thread_usage(thread_id, 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/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index ac38f1110f..4b4a1c0215 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -138,7 +138,7 @@ impl App { &mut self, tui: &mut tui::Tui, thread_id: ThreadId, - request_id: u64, + request_id: Uuid, result: std::result::Result, ) -> Result<()> { let may_update_status = match &result { diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs index 6a5ad7a79e..2eb7b7c058 100644 --- a/codex-rs/tui/src/app/tests/thread_usage.rs +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -10,7 +10,7 @@ use ratatui::layout::Rect; use ratatui::layout::Size; async fn app_with_pending_thread_usage() --> Result<(Box, AppServerSession, tui::Tui, ThreadId, u64)> { +-> Result<(Box, AppServerSession, tui::Tui, ThreadId, Uuid)> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); app.chat_widget.handle_thread_session(test_thread_session( @@ -51,7 +51,7 @@ async fn app_with_pending_thread_usage() Ok((app, app_server, tui, thread_id, request_id)) } -fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent { +fn successful_thread_usage(thread_id: ThreadId, request_id: Uuid) -> AppEvent { AppEvent::ThreadUsageLoaded { thread_id, request_id, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9ccf41bf75..1da9ac4e09 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -959,13 +959,13 @@ pub(crate) enum AppEvent { /// Fetch backend-estimated usage for the currently visible enterprise thread. RefreshThreadUsage { thread_id: ThreadId, - request_id: u64, + request_id: Uuid, }, /// Result of fetching backend-estimated usage for a specific thread. ThreadUsageLoaded { thread_id: ThreadId, - request_id: u64, + request_id: Uuid, result: Result, }, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 8d6590494b..b4a10bace1 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -13,7 +13,6 @@ use crate::model_catalog::LUNA_RESERVE_MODEL; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; -use crate::status::thread_cost_micros; use crate::terminal_program_status::ProgramStatus; use codex_app_server_protocol::AskForApproval; use codex_config::ConfigLayerSource; @@ -831,13 +830,10 @@ impl ChatWidget { ) }), StatusLineItem::ThreadCredits => self - .estimated_thread_usage() - .filter(|_| self.plan_type != Some(PlanType::Ent26)) - .map(|usage| usage.estimated_usage_credits_micros) + .status_line_thread_credits_micros() .map(|credits| format!("{} credits", format_credit_micros(credits))), StatusLineItem::EstimatedThreadCost => self - .estimated_thread_usage() - .and_then(|usage| thread_cost_micros(usage, self.plan_type)) + .status_line_thread_cost_micros() .and_then(format_estimated_usd_micros), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), StatusLineItem::FastMode => Some( diff --git a/codex-rs/tui/src/chatwidget/statusline_state/usage.rs b/codex-rs/tui/src/chatwidget/statusline_state/usage.rs index 92d13d7fb3..f6b006894f 100644 --- a/codex-rs/tui/src/chatwidget/statusline_state/usage.rs +++ b/codex-rs/tui/src/chatwidget/statusline_state/usage.rs @@ -9,6 +9,16 @@ pub(super) struct Usage { context: Context, #[serde(skip_serializing_if = "Option::is_none")] total: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cost: Option, +} + +#[derive(Serialize)] +struct Cost { + #[serde(skip_serializing_if = "Option::is_none")] + total_cost_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_credits: Option, } #[derive(Serialize)] @@ -64,7 +74,14 @@ impl Counts { impl From<&ChatWidget> for Usage { fn from(chat: &ChatWidget) -> Self { let context_window_size = chat.status_line_context_window_size(); + let units = |micros: i64| (micros >= 0).then_some(micros as f64 / 1_000_000.0); + let total_cost_usd = chat.status_line_thread_cost_micros().and_then(units); + let total_credits = chat.status_line_thread_credits_micros().and_then(units); Self { + cost: (total_cost_usd.is_some() || total_credits.is_some()).then_some(Cost { + total_cost_usd, + total_credits, + }), context: Context { context_window_size, used_tokens: chat diff --git a/codex-rs/tui/src/chatwidget/thread_usage.rs b/codex-rs/tui/src/chatwidget/thread_usage.rs index 19ba15a93c..c896163ab1 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage.rs @@ -11,6 +11,7 @@ use super::ThreadId; use crate::status::StatusHistoryHandle; use crate::status::thread_cost_micros; use codex_app_server_protocol::ThreadUsage; +use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ThreadUsageOutcome { @@ -39,8 +40,7 @@ struct SelectedThreadUsage { pub(super) struct ThreadUsageState { thread_id: Option, estimate: Option, - pending_request_id: Option, - next_request_id: u64, + pending_request_id: Option, requested: bool, feature_disabled: bool, status_history_handles: Vec, @@ -50,18 +50,14 @@ pub(super) struct ThreadUsageState { settlement_attempts: usize, settlement_baseline_credits_micros: Option, settlement_baseline_usd_micros: Option, - settlement_request_id: Option, + settlement_request_id: Option, settlement_refresh_due_at: Option, pub(super) replaying_turn_completion: bool, } impl ChatWidget { pub(super) fn clear_thread_usage_state(&mut self) { - let next_request_id = self.thread_usage.next_request_id; - self.thread_usage = ThreadUsageState { - next_request_id, - ..ThreadUsageState::default() - }; + self.thread_usage = ThreadUsageState::default(); } pub(super) fn ensure_thread_usage_requested(&mut self) { @@ -178,16 +174,19 @@ impl ChatWidget { self.thread_usage.settlement_refresh_due_at = None; } + pub(crate) fn is_pending_thread_usage(&self, thread_id: ThreadId, request_id: Uuid) -> bool { + self.thread_id == Some(thread_id) + && self.thread_usage.thread_id == Some(thread_id) + && self.thread_usage.pending_request_id == Some(request_id) + } + pub(crate) fn finish_thread_usage_refresh( &mut self, thread_id: ThreadId, - request_id: u64, + request_id: Uuid, result: Result, ) -> bool { - if self.thread_id != Some(thread_id) - || self.thread_usage.thread_id != Some(thread_id) - || self.thread_usage.pending_request_id != Some(request_id) - { + if !self.is_pending_thread_usage(thread_id, request_id) { return false; } @@ -324,6 +323,16 @@ impl ChatWidget { true } + pub(super) fn status_line_thread_credits_micros(&self) -> Option { + self.estimated_thread_usage() + .filter(|_| self.plan_type != Some(PlanType::Ent26)) + .map(|usage| usage.estimated_usage_credits_micros) + } + + pub(super) fn status_line_thread_cost_micros(&self) -> Option { + thread_cost_micros(self.estimated_thread_usage()?, self.plan_type) + } + pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> { self.thread_usage.estimate.as_ref() } @@ -336,9 +345,7 @@ impl ChatWidget { return; } - let request_id = self.thread_usage.next_request_id; - self.thread_usage.next_request_id = - self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1); + let request_id = Uuid::new_v4(); self.thread_usage.pending_request_id = Some(request_id); self.thread_usage.requested = true; self.thread_usage.thread_id = Some(thread_id); diff --git a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs index f0b525de10..333b2eb62f 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs @@ -2,6 +2,7 @@ use super::THREAD_USAGE_RETRY_DELAYS; use super::THREAD_USAGE_SETTLEMENT_DELAYS; use super::ThreadUsageOutcome; use crate::app_event::AppEvent; +use crate::chatwidget::ChatWidget; use crate::chatwidget::ReplayKind; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::status::format_estimated_usd_micros; @@ -21,8 +22,9 @@ use pretty_assertions::assert_eq; use std::time::Duration; use std::time::Instant; use tokio::sync::mpsc::UnboundedReceiver; +use uuid::Uuid; -fn thread_usage_request_id(events: &mut UnboundedReceiver) -> u64 { +fn thread_usage_request_id(events: &mut UnboundedReceiver) -> Uuid { match events.try_recv() { Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id, event => panic!("expected a thread usage request, got {event:?}"), @@ -43,6 +45,30 @@ fn thread_usage( } } +async fn widget_with_pending_billing_read(thread_id: ThreadId) -> (ChatWidget, Uuid) { + let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await; + chat.thread_id = Some(thread_id); + chat.has_chatgpt_account = true; + chat.has_codex_backend_auth = true; + chat.plan_type = Some(PlanType::Business); + chat.ensure_thread_usage_requested(); + let request_id = thread_usage_request_id(&mut events); + (chat, request_id) +} + +#[tokio::test] +async fn replacement_widget_rejects_a_previous_widgets_read_for_the_same_thread() { + let thread = ThreadId::new(); + let (_, old_request) = widget_with_pending_billing_read(thread).await; + let (mut replacement, current) = widget_with_pending_billing_read(thread).await; + assert!(!replacement.finish_thread_usage_refresh( + thread, + old_request, + Ok(ThreadUsageOutcome::Disabled) + )); + assert!(replacement.is_pending_thread_usage(thread, current)); +} + #[test] fn estimated_cost_formats_dollars_and_sub_cent_values() { assert_eq!( @@ -687,6 +713,13 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() { )); assert!(rx.try_recv().is_err(), "billing surfaces share a request"); + let snapshot = serde_json::to_value(chat.statusline_state()).unwrap(); + let expected_cost = if plan_type == PlanType::Ent26 { + serde_json::json!({"total_cost_usd": 0.75}) + } else { + serde_json::json!({"total_cost_usd": 0.21, "total_credits": 5.2}) + }; + assert_eq!(snapshot["thread"]["usage"]["cost"], expected_cost); if plan_type == PlanType::Ent26 { insta::assert_snapshot!(chat.status_line_text().unwrap(), @"~$0.75"); insta::assert_snapshot!(chat.last_terminal_title.as_ref().unwrap(), @"~$0.75");