diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 2b70571738..bf7883e7ba 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3070,6 +3070,13 @@ "type": "null" } ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c25c650d81..34d3df5793 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14823,6 +14823,13 @@ "type": "null" } ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 59c5e8b9db..53bba61d9a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11220,6 +11220,13 @@ "type": "null" } ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index c518085df9..c2dfb3e499 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -122,6 +122,13 @@ "type": "null" } ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index a2883a1293..9d75ca14c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -208,6 +208,13 @@ "type": "null" } ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": [ + "boolean", + "null" + ] } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index c1e3953dfc..13c1604b9e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -7,4 +7,8 @@ import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, +/** + * Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery. + */ +spendControlReached: boolean | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index ec44ef3959..9a0f796d20 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -532,6 +532,8 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub individual_limit: Option, + /// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery. + pub spend_control_reached: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -545,6 +547,7 @@ impl From for RateLimitSnapshot { secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from), + spend_control_reached: value.spend_control_reached, plan_type: value.plan_type, rate_limit_reached_type: value .rate_limit_reached_type diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e2a80ac265..4b83bed356 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1939,11 +1939,12 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated` on success. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. -- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only. +- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. + `spendControlReached` is `true` or `false` when the backend reports spend-control state; `null` means unavailable and must not clear a previously observed value in a sparse update. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, threadId, success, error? }`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error, failureReason }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`. `failureReason` is `reauthenticationRequired` when stored OAuth credentials have expired and cannot be refreshed, so clients can prompt the user to reconnect the named server. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b3491afedd..0f181b5e7d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3715,6 +3715,7 @@ mod tests { balance: Some("5".to_string()), }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 7f93f27cb8..1351868d47 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -838,6 +838,7 @@ mod tests { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: Some(PlanType::Plus), rate_limit_reached_type: None, }, @@ -858,6 +859,7 @@ mod tests { "secondary": null, "credits": null, "individualLimit": null, + "spendControlReached": null, "planType": "plus", "rateLimitReachedType": null } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 9c9cbc5ac5..5518ed132c 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -260,6 +260,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { remaining_percent: 68, resets_at: secondary_reset_timestamp, }), + spend_control_reached: Some(false), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), }, @@ -287,6 +288,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { remaining_percent: 68, resets_at: secondary_reset_timestamp, }), + spend_control_reached: Some(false), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some( RateLimitReachedType::WorkspaceMemberUsageLimitReached, @@ -306,6 +308,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index a5db25618b..0ae1102d6b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -490,17 +490,12 @@ impl Client { .rate_limit_reached_type .flatten() .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); - let individual_limit = payload - .spend_control - .flatten() - .and_then(|details| details.individual_limit.flatten()) - .map(|details| Self::map_individual_limit(*details)); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, payload.rate_limit.flatten().map(|details| *details), payload.credits.flatten().map(|details| *details), - individual_limit, + payload.spend_control.flatten().map(|details| *details), plan_type, rate_limit_reached_type, )]; @@ -511,7 +506,7 @@ impl Client { Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, - /*individual_limit*/ None, + /*spend_control*/ None, plan_type, /*rate_limit_reached_type*/ None, ) @@ -525,7 +520,7 @@ impl Client { limit_name: Option, rate_limit: Option, credits: Option, - individual_limit: Option, + spend_control: Option, plan_type: Option, rate_limit_reached_type: Option, ) -> RateLimitSnapshot { @@ -536,6 +531,10 @@ impl Client { ), None => (None, None), }; + let spend_control_reached = spend_control.as_ref().map(|details| details.reached); + let individual_limit = spend_control + .and_then(|details| details.individual_limit.flatten()) + .map(|details| Self::map_individual_limit(*details)); RateLimitSnapshot { limit_id, limit_name, @@ -543,6 +542,7 @@ impl Client { secondary, credits: Self::map_credits(credits), individual_limit, + spend_control_reached, plan_type, rate_limit_reached_type, } @@ -767,6 +767,7 @@ mod tests { }) ); assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); + assert_eq!(snapshots[0].spend_control_reached, Some(false)); assert_eq!( snapshots[0].rate_limit_reached_type, Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) @@ -789,6 +790,7 @@ mod tests { ); assert_eq!(snapshots[1].credits, None); assert_eq!(snapshots[1].individual_limit, None); + assert_eq!(snapshots[1].spend_control_reached, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[1].rate_limit_reached_type, None); } @@ -817,6 +819,29 @@ mod tests { assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); } + #[test] + fn usage_payload_maps_spend_control_reached_without_individual_limit() { + let payload = RateLimitStatusPayload { + plan_type: crate::types::PlanType::EnterpriseCbpUsageBased, + rate_limit: None, + additional_rate_limits: None, + credits: None, + spend_control: Some(Some(Box::new( + codex_backend_openapi_models::models::SpendControlStatusDetails { + reached: true, + individual_limit: None, + }, + ))), + rate_limit_reached_type: None, + }; + + let snapshots = Client::rate_limit_snapshots_from_payload(payload); + + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].spend_control_reached, Some(true)); + assert_eq!(snapshots[0].individual_limit, None); + } + #[test] fn preferred_snapshot_selection_matches_get_rate_limits_behavior() { let snapshots = [ @@ -831,6 +856,7 @@ mod tests { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, @@ -845,6 +871,7 @@ mod tests { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index ab1b409eb1..a56488b6ea 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -86,12 +86,18 @@ pub fn map_api_error(err: ApiError) -> CodexErr { if let Ok(err) = serde_json::from_str::(&body_text) { if err.error.error_type.as_deref() == Some("usage_limit_reached") { let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER); - let rate_limits = headers.as_ref().and_then(|map| { - parse_rate_limit_for_limit(map, limit_id.as_deref()) - }); let promo_message = headers.as_ref().and_then(parse_promo_message); let rate_limit_reached_type = headers.as_ref().and_then(parse_rate_limit_reached_type); + let rate_limits = headers + .as_ref() + .and_then(|map| { + parse_rate_limit_for_limit(map, limit_id.as_deref()) + }) + .map(|mut snapshot| { + snapshot.rate_limit_reached_type = rate_limit_reached_type; + snapshot + }); let resets_at = err .error .resets_at diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 5812fc19fe..4e531cbbfa 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -1,5 +1,6 @@ use super::*; use base64::Engine; +use codex_protocol::protocol::RateLimitReachedType; use pretty_assertions::assert_eq; #[test] @@ -222,6 +223,70 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() { ); } +#[test] +fn map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot() { + for (active_limit, expected_limit_id) in [(None, "codex"), (Some("codex_other"), "codex_other")] + { + let mut headers = HeaderMap::new(); + if let Some(active_limit) = active_limit { + headers.insert( + ACTIVE_LIMIT_HEADER, + http::HeaderValue::from_static(active_limit), + ); + } + for (name, value) in [ + ("x-codex-credits-has-credits", "true"), + ("x-codex-credits-unlimited", "false"), + ("x-codex-credits-balance", ""), + ( + "x-codex-rate-limit-reached-type", + "workspace_member_usage_limit_reached", + ), + ] { + headers.insert(name, http::HeaderValue::from_static(value)); + } + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!( + usage_limit.rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + ); + let snapshot = usage_limit + .rate_limits + .as_ref() + .expect("usage limit snapshot"); + assert_eq!(snapshot.limit_id.as_deref(), Some(expected_limit_id)); + assert_eq!( + snapshot.rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + ); + assert_eq!( + snapshot.credits.as_ref().map(|credits| ( + credits.has_credits, + credits.unlimited, + credits.balance.as_deref() + )), + Some((true, false, None)) + ); + } +} + #[test] fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() { let values = [ diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 91f96fc3b8..d0f936a81f 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -94,6 +94,7 @@ pub fn parse_rate_limit_for_limit( secondary, credits, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }) @@ -159,6 +160,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { secondary, credits, individual_limit: None, + spend_control_reached: None, plan_type: event.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b27a280eac..8c0aff8260 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3795,6 +3795,7 @@ async fn set_rate_limits_retains_previous_credits() { balance: Some("10.00".to_string()), }), individual_limit: None, + spend_control_reached: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3815,6 +3816,7 @@ async fn set_rate_limits_retains_previous_credits() { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -3829,6 +3831,7 @@ async fn set_rate_limits_retains_previous_credits() { secondary: update.secondary, credits: initial.credits, individual_limit: initial.individual_limit, + spend_control_reached: initial.spend_control_reached, plan_type: initial.plan_type, rate_limit_reached_type: None, }) @@ -3905,6 +3908,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { balance: Some("15.00".to_string()), }), individual_limit: None, + spend_control_reached: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3921,6 +3925,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: Some(codex_protocol::account::PlanType::Pro), rate_limit_reached_type: None, }; @@ -3935,6 +3940,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { secondary: update.secondary, credits: initial.credits, individual_limit: initial.individual_limit, + spend_control_reached: initial.spend_control_reached, plan_type: update.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index d681c6fa87..d769ec3872 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -327,6 +327,9 @@ fn merge_rate_limit_fields( if snapshot.individual_limit.is_none() { snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone()); } + if snapshot.spend_control_reached.is_none() { + snapshot.spend_control_reached = previous.and_then(|prior| prior.spend_control_reached); + } if snapshot.plan_type.is_none() { snapshot.plan_type = previous.and_then(|prior| prior.plan_type); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index 0fbb92b958..44011b7d82 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -51,6 +51,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }); @@ -96,6 +97,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }); @@ -110,6 +112,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }); @@ -148,6 +151,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { remaining_percent: 68, resets_at: 300, }), + spend_control_reached: Some(true), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }); @@ -163,6 +167,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }); @@ -189,8 +194,29 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { remaining_percent: 68, resets_at: 300, }), + spend_control_reached: Some(true), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }) ); + + state.set_rate_limits(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: None, + primary: None, + secondary: None, + credits: None, + individual_limit: None, + spend_control_reached: Some(false), + plan_type: None, + rate_limit_reached_type: None, + }); + + assert_eq!( + state + .latest_rate_limits + .as_ref() + .and_then(|snapshot| snapshot.spend_control_reached), + Some(false) + ); } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e9c4059948..85976137aa 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -3324,6 +3324,7 @@ async fn token_count_includes_rate_limits_snapshot() { }, "credits": null, "individual_limit": null, + "spend_control_reached": null, "plan_type": null, "rate_limit_reached_type": null } @@ -3365,6 +3366,13 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { .insert_header("x-codex-primary-over-secondary-limit-percent", "95.0") .insert_header("x-codex-primary-window-minutes", "15") .insert_header("x-codex-secondary-window-minutes", "60") + .insert_header("x-codex-credits-has-credits", "true") + .insert_header("x-codex-credits-unlimited", "false") + .insert_header("x-codex-credits-balance", "") + .insert_header( + "x-codex-rate-limit-reached-type", + "workspace_member_usage_limit_reached", + ) .set_body_json(json!({ "error": { "type": "usage_limit_reached", @@ -3398,10 +3406,15 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "window_minutes": 60, "resets_at": null }, - "credits": null, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": null + }, "individual_limit": null, + "spend_control_reached": null, "plan_type": null, - "rate_limit_reached_type": null + "rate_limit_reached_type": "workspace_member_usage_limit_reached" }); let submission_id = codex @@ -3437,7 +3450,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { unreachable!(); }; assert!( - error_event.message.to_lowercase().contains("usage limit"), + error_event.message.contains("spend cap set by the owner"), "unexpected error message for submission {submission_id}: {}", error_event.message ); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9bf39425ab..d76970a190 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1483,7 +1483,11 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "x-codex-secondary-used-percent": "87.5", "x-codex-primary-over-secondary-limit-percent": "95.0", "x-codex-primary-window-minutes": "15", - "x-codex-secondary-window-minutes": "60" + "x-codex-secondary-window-minutes": "60", + "x-codex-credits-has-credits": "true", + "x-codex-credits-unlimited": "false", + "x-codex-credits-balance": "", + "x-codex-rate-limit-reached-type": "workspace_member_usage_limit_reached" } }); @@ -1543,10 +1547,15 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "window_minutes": 60, "resets_at": null }, - "credits": null, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": null + }, "individual_limit": null, + "spend_control_reached": null, "plan_type": null, - "rate_limit_reached_type": null + "rate_limit_reached_type": "workspace_member_usage_limit_reached" } }) ); @@ -1556,7 +1565,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { unreachable!(); }; assert!( - error_event.message.to_lowercase().contains("usage limit"), + error_event.message.contains("spend cap set by the owner"), "unexpected error message for submission {submission_id}: {}", error_event.message ); diff --git a/codex-rs/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 0659aabd19..551f36a52b 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -12,6 +12,7 @@ fn snapshot( secondary: secondary_used_percent.map(window), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 0adbd029cd..b6fd84cbbb 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -36,6 +36,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8ccdf59032..d0ac433beb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2124,6 +2124,8 @@ pub struct RateLimitSnapshot { pub secondary: Option, pub credits: Option, pub individual_limit: Option, + /// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery. + pub spend_control_reached: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cffd90edb8..75c7e692fa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -577,6 +577,8 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, pending_startup_thread_start: bool, + /// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives. + rate_limit_hard_stop_generation: u64, // Serialize plugin enablement writes per plugin so stale completions cannot // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. @@ -1066,6 +1068,7 @@ See the Codex keymap documentation for supported actions and examples." pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start, + rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }; diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 06ed4320d8..188fbd8dc8 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -12,6 +12,7 @@ use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -76,6 +77,19 @@ impl App { self.refresh_mcp_startup_expected_servers_from_config(); } ServerNotification::AccountRateLimitsUpdated(notification) => { + if matches!( + notification.rate_limits.rate_limit_reached_type, + Some( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted + | RateLimitReachedType::WorkspaceMemberCreditsDepleted + | RateLimitReachedType::WorkspaceOwnerUsageLimitReached + | RateLimitReachedType::WorkspaceMemberUsageLimitReached + ) + ) || notification.rate_limits.spend_control_reached == Some(true) + { + self.rate_limit_hard_stop_generation = + self.rate_limit_hard_stop_generation.wrapping_add(1); + } self.chat_widget .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); return; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 6e2fd0ecbd..0036495a8d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -80,6 +80,7 @@ impl App { ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); + let hard_stop_generation = self.rate_limit_hard_stop_generation; tokio::spawn(async move { let request = fetch_account_rate_limits(request_handle); let result = match origin { @@ -96,7 +97,11 @@ impl App { request.await.map_err(|err| err.to_string()) } }; - app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result }); + app_event_tx.send(AppEvent::RateLimitsLoaded { + origin, + hard_stop_generation, + result, + }); }); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 4a2f0305e8..10951bc39c 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -832,10 +832,19 @@ impl App { self.chat_widget .finish_add_credits_nudge_email_request(result); } - AppEvent::RateLimitsLoaded { origin, result } => match result { + AppEvent::RateLimitsLoaded { + origin, + hard_stop_generation, + result, + } => match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - let snapshots = app_server_rate_limit_snapshots(response); + let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation + { + app_server_rate_limit_snapshots(response) + } else { + Vec::new() + }; match origin { RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index e0eafaa23b..05240df9bd 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -64,6 +64,7 @@ pub(super) async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 51ecd0106a..ce15b2b38e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4,6 +4,7 @@ mod advanced_reasoning_tests; mod model_catalog; mod plugin_catalog; +mod rate_limits; mod session_summary; mod startup; @@ -4104,6 +4105,7 @@ async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), } @@ -4169,6 +4171,7 @@ async fn make_test_app_with_channels() -> ( pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_startup_thread_start: false, + rate_limit_hard_stop_generation: 0, pending_plugin_enabled_writes: HashMap::new(), pending_hook_enabled_writes: HashMap::new(), }, diff --git a/codex-rs/tui/src/app/tests/rate_limits.rs b/codex-rs/tui/src/app/tests/rate_limits.rs new file mode 100644 index 0000000000..d1a416334f --- /dev/null +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -0,0 +1,388 @@ +use super::*; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CreditsSnapshot; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; + +fn rate_limit_snapshot( + used_percent: i32, + rate_limit_reached_type: Option, + spend_control_reached: Option, +) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + primary: Some(RateLimitWindow { + used_percent, + window_duration_mins: Some(300), + resets_at: None, + }), + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }), + individual_limit: None, + spend_control_reached, + plan_type: None, + rate_limit_reached_type, + } +} + +fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse { + GetAccountRateLimitsResponse { + rate_limits: snapshot, + rate_limits_by_limit_id: None, + rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }), + } +} + +async fn deliver_rolling_rate_limit_snapshot( + app: &mut App, + app_server: &AppServerSession, + snapshot: RateLimitSnapshot, +) { + app.handle_app_server_event( + app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { + rate_limits: snapshot, + }), + ), + ) + .await; +} + +fn render_status_output( + app: &mut App, + app_event_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> String { + while app_event_rx.try_recv().is_ok() {} + app.chat_widget.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + other => panic!("expected status output, got {other:?}"), + } +} + +fn deliver_usage_limit_error(app: &mut App) { + app.chat_widget.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + message: "Usage limit reached.".to_string(), + codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }), + /*replay_kind*/ None, + ); +} + +#[tokio::test] +async fn rolling_workspace_hard_stops_invalidate_older_rate_limit_reads() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + + let cases = [ + (None, None, false), + (Some(RateLimitReachedType::RateLimitReached), None, false), + (None, Some(false), false), + (None, Some(true), true), + ( + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + None, + true, + ), + ]; + let mut expected_generation = 0; + for (reached_type, spend_control_reached, invalidates) in cases { + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + reached_type, + spend_control_reached, + ), + ) + .await; + if invalidates { + expected_generation += 1; + } + assert_eq!( + app.rate_limit_hard_stop_generation, expected_generation, + "reached_type={reached_type:?}, spend_control_reached={spend_control_reached:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_origin() -> Result<()> +{ + for origin_name in [ + "startup", + "status", + "usage", + "reset-picker", + "reset-consume", + ] { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + + let origin = match origin_name { + "startup" => RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id: app.chat_widget.start_rate_limit_reset_startup_check(), + }, + "status" => { + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + RateLimitRefreshOrigin::StatusCommand { request_id } + } + "usage" => { + let startup_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); + app.chat_widget.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }), + ); + app.chat_widget + .set_composer_text("/usage".to_string(), Vec::new(), Vec::new()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + loop { + match app_event_rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { origin }) => break origin, + Ok(_) => {} + other => panic!("expected usage refresh request, got {other:?}"), + } + } + } + "reset-picker" => RateLimitRefreshOrigin::ResetPicker { + request_id: app.chat_widget.show_rate_limit_reset_loading_popup(), + }, + "reset-consume" => RateLimitRefreshOrigin::ResetConsume { + request_id: app.chat_widget.show_rate_limit_reset_consuming_popup(), + }, + _ => unreachable!("unknown refresh origin"), + }; + let read_generation = app.rate_limit_hard_stop_generation; + let mut rolling_snapshot = rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ); + if origin_name == "reset-picker" { + rolling_snapshot.limit_id = Some("codex_other".to_string()); + } + deliver_rolling_rate_limit_snapshot(&mut app, &app_server, rolling_snapshot).await; + assert_ne!(read_generation, app.rate_limit_hard_stop_generation); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + origin, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + match origin_name { + "usage" => assert!(popup.contains("No usage limit resets available.")), + "reset-picker" => { + assert!(popup.contains("You don't have any usage limit resets available.")); + } + "reset-consume" => { + assert!(popup.contains("Usage reset. You have 0 usage limit resets left.")); + } + "startup" | "status" => {} + _ => unreachable!("unknown refresh origin"), + } + + let status = render_status_output(&mut app, &mut app_event_rx); + assert!( + status.contains("5% left"), + "expected {origin_name} to preserve rolling limits, got: {status}" + ); + deliver_usage_limit_error(&mut app); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "expected {origin_name} to preserve workspace error routing, got: {popup}" + ); + + app_server.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn stale_rate_limit_read_does_not_dismiss_visible_workspace_advisory() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + let read_generation = app.rate_limit_hard_stop_generation; + + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ), + ) + .await; + app.chat_widget.handle_server_notification( + turn_completed_notification(ThreadId::new(), "turn-1", TurnStatus::Completed), + /*replay_kind*/ None, + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits") + ); + + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits") + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ), + ) + .await; + let read_generation = app.rate_limit_hard_stop_generation; + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let status = render_status_output(&mut app, &mut app_event_rx); + assert!( + status.contains("100% left"), + "expected recovered limits, got: {status}" + ); + deliver_usage_limit_error(&mut app); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + !popup.contains("Request a limit increase from your owner"), + "expected recovered state to clear workspace error routing, got: {popup}" + ); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6661ae5af4..10854db44c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -317,6 +317,7 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits. RateLimitsLoaded { origin: RateLimitRefreshOrigin, + hard_stop_generation: u64, result: Result, }, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 8513028ed0..5ad218ea44 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1846,6 +1846,7 @@ mod tests { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 71d42b8fb2..7a838f436b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -565,6 +565,7 @@ pub(crate) struct ChatWidget { next_rate_limit_reset_request_id: u64, plan_type: Option, codex_rate_limit_reached_type: Option, + codex_spend_control_reached: Option, rate_limit_warnings: RateLimitWarningState, warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 5e5286962e..f0f6e44446 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -137,6 +137,7 @@ impl ChatWidget { next_rate_limit_reset_request_id: 0, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, + codex_spend_control_reached: None, rate_limit_warnings: RateLimitWarningState::default(), warning_display_state: WarningDisplayState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index bb0d07c3a1..ba3e10082b 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -157,6 +157,10 @@ enum RateLimitSnapshotSource { RollingUpdate, } +fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool { + credits.unlimited || credits.has_credits +} + impl ChatWidget { pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage); @@ -181,7 +185,9 @@ impl ChatWidget { .limit_name .clone() .unwrap_or_else(|| limit_id.clone()); - if snapshot.credits.is_none() { + if matches!(source, RateLimitSnapshotSource::RollingUpdate) + && snapshot.credits.is_none() + { snapshot.credits = self .rate_limit_snapshots_by_limit_id .get(&limit_id) @@ -206,21 +212,43 @@ impl ChatWidget { let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); if is_codex_limit - && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type + && (matches!(source, RateLimitSnapshotSource::AccountUsage) + || snapshot.spend_control_reached.is_some()) { - self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); + self.codex_spend_control_reached = snapshot.spend_control_reached; + } + if (is_codex_limit && matches!(source, RateLimitSnapshotSource::AccountUsage)) + || snapshot.rate_limit_reached_type.is_some() + { + self.codex_rate_limit_reached_type = snapshot.rate_limit_reached_type; + } + let workspace_limit_reached = self.codex_spend_control_reached == Some(true) + || matches!( + self.codex_rate_limit_reached_type, + Some( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted + | RateLimitReachedType::WorkspaceMemberCreditsDepleted + | RateLimitReachedType::WorkspaceOwnerUsageLimitReached + | RateLimitReachedType::WorkspaceMemberUsageLimitReached + ) + ); + let has_workspace_credits = !workspace_limit_reached + && snapshot + .credits + .as_ref() + .is_some_and(has_usable_workspace_credits); + if is_codex_limit && has_workspace_credits { + match self.rate_limit_switch_prompt { + RateLimitSwitchPromptState::Pending => { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + RateLimitSwitchPromptState::Shown => { + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID); + } + RateLimitSwitchPromptState::Idle => {} + } } - - let has_workspace_credits = snapshot.credits.as_ref().is_some_and(|credits| { - credits.has_credits - && (credits.unlimited - || credits.balance.as_deref().is_some_and(|balance| { - balance - .trim() - .parse::() - .is_ok_and(|balance| balance > 0.0) - })) - }); let should_warn_about_rate_limit_usage = is_codex_limit && !has_workspace_credits; let warnings = if should_warn_about_rate_limit_usage { self.rate_limit_warnings.take_warnings( @@ -286,6 +314,7 @@ impl ChatWidget { } else { self.rate_limit_snapshots_by_limit_id.clear(); self.codex_rate_limit_reached_type = None; + self.codex_spend_control_reached = None; } self.refresh_status_line(); } diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 48224ea1dd..73dbdb33c8 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -219,6 +219,7 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); self.codex_rate_limit_reached_type = None; + self.codex_spend_control_reached = None; self.rate_limit_warnings = RateLimitWarningState::default(); self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; self.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 6e522b4235..f055777bc2 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -113,6 +113,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: 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 7c52269adb..5c72382357 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -593,6 +593,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -622,6 +623,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -655,6 +657,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -684,6 +687,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -713,6 +717,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -742,6 +747,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -771,6 +777,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); let initial_balance = chat @@ -780,7 +787,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { .and_then(|credits| credits.balance.as_deref()); assert_eq!(initial_balance, Some("17.5")); - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + chat.on_rolling_rate_limit_snapshot(RateLimitSnapshot { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { @@ -792,8 +799,9 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, - })); + }); let display = chat .rate_limit_snapshots_by_limit_id @@ -866,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { credits: None, individual_limit: None, plan_type: Some(PlanType::Plus), + spend_control_reached: None, rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Plus)); @@ -886,6 +895,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { credits: None, individual_limit: None, plan_type: Some(PlanType::Pro), + spend_control_reached: None, rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -906,6 +916,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -931,6 +942,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { }), individual_limit: None, plan_type: Some(PlanType::Pro), + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -946,6 +958,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { credits: None, individual_limit: None, plan_type: Some(PlanType::Pro), + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -1000,6 +1013,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { credits: None, individual_limit: None, plan_type: None, + spend_control_reached: None, rate_limit_reached_type: None, })); @@ -1010,22 +1024,102 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { } #[tokio::test] -async fn rate_limit_usage_warnings_show_when_workspace_credits_zero_balance() { +async fn rate_limit_usage_warnings_follow_workspace_credit_flags() { + for (credits, should_warn) in [ + ( + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }, + false, + ), + ( + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some(String::new()), + }, + false, + ), + ( + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("0".to_string()), + }, + false, + ), + ( + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("not-a-number".to_string()), + }, + false, + ), + ( + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("25.00".to_string()), + }, + false, + ), + ( + CreditsSnapshot { + has_credits: false, + unlimited: true, + balance: None, + }, + false, + ), + ( + CreditsSnapshot { + has_credits: false, + unlimited: false, + balance: Some("25.00".to_string()), + }, + true, + ), + ] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0); + rate_limit_snapshot.credits = Some(credits); + + chat.on_rate_limit_snapshot(Some(rate_limit_snapshot)); + + assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn); + assert_eq!( + matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ), + should_warn + ); + assert_eq!(chat.rate_limit_warnings.primary_index > 0, should_warn); + } +} + +#[tokio::test] +async fn rate_limit_usage_warnings_show_when_authoritative_snapshot_clears_credits() { let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.has_chatgpt_account = true; - let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0); - rate_limit_snapshot.credits = Some(CreditsSnapshot { + let mut initial_snapshot = snapshot(/*percent*/ 0.0); + initial_snapshot.credits = Some(CreditsSnapshot { has_credits: true, unlimited: false, - balance: Some("0".to_string()), + balance: None, }); + chat.on_rate_limit_snapshot(Some(initial_snapshot)); - chat.on_rate_limit_snapshot(Some(rate_limit_snapshot)); + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); assert!( !drain_insert_history(&mut rx).is_empty(), - "zero-balance workspace credits should not suppress proactive usage warnings" + "an authoritative snapshot without credits should clear stale credit availability" ); assert!(matches!( chat.rate_limit_switch_prompt, @@ -1034,55 +1128,396 @@ async fn rate_limit_usage_warnings_show_when_workspace_credits_zero_balance() { } #[tokio::test] -async fn rate_limit_usage_warnings_skip_when_workspace_credits_are_available() { - let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; +async fn rate_limit_switch_prompt_clears_pending_when_workspace_credits_become_usable() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.has_chatgpt_account = true; - let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0); - rate_limit_snapshot.credits = Some(CreditsSnapshot { + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + + let mut funded_snapshot = snapshot(/*percent*/ 95.0); + funded_snapshot.credits = Some(CreditsSnapshot { has_credits: true, unlimited: false, - balance: Some("25.00".to_string()), + balance: None, }); + chat.on_rate_limit_snapshot(Some(funded_snapshot)); - chat.on_rate_limit_snapshot(Some(rate_limit_snapshot)); - - assert!( - drain_insert_history(&mut rx).is_empty(), - "workspace credits should suppress proactive usage warnings" - ); assert!(matches!( chat.rate_limit_switch_prompt, RateLimitSwitchPromptState::Idle )); +} + +#[tokio::test] +async fn rate_limit_switch_prompt_dismisses_shown_when_workspace_credits_become_usable() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + + let mut funded_snapshot = snapshot(/*percent*/ 95.0); + funded_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + chat.on_rate_limit_snapshot(Some(funded_snapshot)); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(chat.bottom_pane.no_modal_or_popup_active()); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 0.0))); + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); + chat.maybe_show_pending_rate_limit_prompt(); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + +#[tokio::test] +async fn rate_limit_usage_warnings_preserve_workspace_limit_for_sparse_snapshots() { + for rate_limit_reached_type in [ + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut blocked_snapshot = snapshot(/*percent*/ 0.0); + blocked_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type); + chat.on_rate_limit_snapshot(Some(blocked_snapshot)); + + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0)); + + assert!( + !drain_insert_history(&mut rx).is_empty(), + "an explicit workspace hard stop should keep proactive usage warnings enabled" + ); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + } +} + +#[tokio::test] +async fn rate_limit_usage_warnings_keep_workspace_limit_after_rolling_credits() { + for rate_limit_reached_type in [ + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ] { + for credits in [ + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }, + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some(String::new()), + }, + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("25.00".to_string()), + }, + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("0".to_string()), + }, + CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: Some("not-a-number".to_string()), + }, + CreditsSnapshot { + has_credits: false, + unlimited: true, + balance: None, + }, + ] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut blocked_snapshot = snapshot(/*percent*/ 0.0); + blocked_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type); + chat.on_rolling_rate_limit_snapshot(blocked_snapshot); + + let mut rolling_snapshot = snapshot(/*percent*/ 95.0); + rolling_snapshot.credits = Some(credits); + chat.on_rolling_rate_limit_snapshot(rolling_snapshot); + + assert!( + !drain_insert_history(&mut rx).is_empty(), + "usable rolling workspace credits must not suppress an existing workspace hard stop" + ); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!( + chat.codex_rate_limit_reached_type, + Some(rate_limit_reached_type) + ); + } + } +} + +#[tokio::test] +async fn rate_limit_usage_warnings_keep_explicit_rolling_workspace_limit() { + for rate_limit_reached_type in [ + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + ] { + for spend_control_reached in [None, Some(false)] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut rolling_snapshot = snapshot(/*percent*/ 95.0); + rolling_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + rolling_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type); + rolling_snapshot.spend_control_reached = spend_control_reached; + chat.on_rolling_rate_limit_snapshot(rolling_snapshot); + + assert!( + !drain_insert_history(&mut rx).is_empty(), + "an explicit rolling workspace hard stop should keep proactive usage warnings enabled" + ); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!( + chat.codex_rate_limit_reached_type, + Some(rate_limit_reached_type) + ); + } + } +} + +#[tokio::test] +async fn rate_limit_usage_warnings_keep_newly_reached_workspace_limit() { + for (limit_id, should_warn) in [("codex", true), ("codex_other", false)] { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut initial_snapshot = snapshot(/*percent*/ 0.0); + initial_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + initial_snapshot.spend_control_reached = Some(false); + chat.on_rate_limit_snapshot(Some(initial_snapshot)); + + let mut capped_snapshot = snapshot(/*percent*/ 95.0); + capped_snapshot.limit_id = Some(limit_id.to_string()); + capped_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + capped_snapshot.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rolling_rate_limit_snapshot(capped_snapshot); + + assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn); + assert_eq!( + matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ), + should_warn + ); + assert_eq!( + chat.codex_rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + ); + + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!(popup.contains("Request a limit increase from your owner")); + } +} + +#[tokio::test] +async fn rate_limit_usage_warnings_preserve_and_clear_spend_control_state() { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut blocked_snapshot = snapshot(/*percent*/ 0.0); + blocked_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + blocked_snapshot.spend_control_reached = Some(true); + blocked_snapshot.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(blocked_snapshot)); + assert_eq!(chat.codex_spend_control_reached, Some(true)); + + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0)); + assert!( + !drain_insert_history(&mut rx).is_empty(), + "a sparse rolling snapshot should preserve a reached spend control" + ); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!(chat.codex_spend_control_reached, Some(true)); + + let mut recovered_snapshot = snapshot(/*percent*/ 95.0); + recovered_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + recovered_snapshot.spend_control_reached = Some(false); + chat.on_rolling_rate_limit_snapshot(recovered_snapshot); + + assert!(drain_insert_history(&mut rx).is_empty()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!(chat.codex_spend_control_reached, Some(false)); assert_eq!( - chat.rate_limit_warnings.primary_index, 0, - "suppressed warnings should not consume warning thresholds" + chat.codex_rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) + ); + + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0)); + assert!( + drain_insert_history(&mut rx).is_empty(), + "a later sparse rolling snapshot should not clear an existing workspace hard stop" + ); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!(chat.codex_spend_control_reached, Some(false)); + assert_eq!( + chat.codex_rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) ); } #[tokio::test] -async fn rate_limit_usage_warnings_skip_with_unlimited_workspace_credits() { +async fn rolling_credits_preserve_depleted_workspace_error_routing() { let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.has_chatgpt_account = true; - let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0); - rate_limit_snapshot.credits = Some(CreditsSnapshot { + let mut blocked_snapshot = snapshot(/*percent*/ 0.0); + blocked_snapshot.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted); + chat.on_rate_limit_snapshot(Some(blocked_snapshot)); + + let mut rolling_snapshot = snapshot(/*percent*/ 95.0); + rolling_snapshot.credits = Some(CreditsSnapshot { has_credits: true, - unlimited: true, + unlimited: false, + balance: Some("0".to_string()), + }); + rolling_snapshot.spend_control_reached = Some(false); + chat.on_rolling_rate_limit_snapshot(rolling_snapshot); + + assert!(!drain_insert_history(&mut rx).is_empty()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + assert_eq!( + chat.codex_rate_limit_reached_type, + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) + ); + + chat.on_rate_limit_error( + RateLimitErrorKind::Generic, + "Usage limit reached.".to_string(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains("Ask your workspace owner to add more"), + "popup: {popup}" + ); +} + +#[tokio::test] +async fn rate_limit_usage_warnings_clear_workspace_limit_from_authoritative_snapshot() { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await; + chat.has_chatgpt_account = true; + + let mut blocked_snapshot = snapshot(/*percent*/ 0.0); + blocked_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, balance: None, }); + blocked_snapshot.rate_limit_reached_type = + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(blocked_snapshot)); - chat.on_rate_limit_snapshot(Some(rate_limit_snapshot)); + let mut recovered_snapshot = snapshot(/*percent*/ 0.0); + recovered_snapshot.credits = Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }); + chat.on_rate_limit_snapshot(Some(recovered_snapshot)); + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0)); assert!( drain_insert_history(&mut rx).is_empty(), - "unlimited workspace credits should suppress proactive usage warnings" + "an authoritative recovery should prevent sparse updates from restoring a stale limit" ); assert!(matches!( chat.rate_limit_switch_prompt, RateLimitSwitchPromptState::Idle )); + assert_eq!(chat.codex_rate_limit_reached_type, None); } #[tokio::test] @@ -1114,11 +1549,13 @@ async fn account_update_clears_derived_usage_limit_state_and_prompt() { set_chatgpt_auth(&mut chat); let mut limits = snapshot(/*percent*/ 95.0); limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + limits.spend_control_reached = Some(true); chat.on_rate_limit_snapshot(Some(limits)); chat.maybe_show_pending_rate_limit_prompt(); assert!(chat.rate_limit_warnings.primary_index > 0); assert!(chat.codex_rate_limit_reached_type.is_some()); + assert_eq!(chat.codex_spend_control_reached, Some(true)); assert!(matches!( chat.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -1133,6 +1570,7 @@ async fn account_update_clears_derived_usage_limit_state_and_prompt() { assert_eq!(chat.rate_limit_warnings.primary_index, 0); assert_eq!(chat.rate_limit_warnings.secondary_index, 0); assert_eq!(chat.codex_rate_limit_reached_type, None); + assert_eq!(chat.codex_spend_control_reached, None); assert!(matches!( chat.rate_limit_switch_prompt, RateLimitSwitchPromptState::Idle @@ -1226,18 +1664,16 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { } #[tokio::test] -async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() { +async fn sparse_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut usage_limits = snapshot(/*percent*/ 100.0); usage_limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); chat.on_rate_limit_snapshot(Some(usage_limits)); - // Turn-failure snapshots are derived from response headers and do not carry - // the backend-classified reached type. They arrive before the Error event. - let mut header_limits = snapshot(/*percent*/ 100.0); - header_limits.rate_limit_reached_type = None; - chat.on_rate_limit_snapshot(Some(header_limits)); + let mut rolling_limits = snapshot(/*percent*/ 100.0); + rolling_limits.rate_limit_reached_type = None; + chat.on_rolling_rate_limit_snapshot(rolling_limits); chat.on_rate_limit_error( RateLimitErrorKind::UsageLimit, diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 63005cb957..9e38065a8c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -77,6 +77,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, })); @@ -247,6 +248,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, })); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 2054f799d6..aaf54df448 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -357,25 +357,30 @@ pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String { format!("{percent_remaining:.0}% left") } -/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates -/// that the account has credit tracking enabled. When credits are unlimited we -/// show that fact explicitly; otherwise we render the rounded balance in -/// credits. Accounts with credits = 0 skip this section entirely. +/// Builds a single `StatusRateLimitRow` when workspace credits are available. +/// Unlimited credits are shown explicitly; finite credits show their rounded +/// balance or `Available` when the balance is hidden. fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option { - if !credits.has_credits { - return None; - } if credits.unlimited { return Some(StatusRateLimitRow { label: "Credits".to_string(), value: StatusRateLimitValue::Text("Unlimited".to_string()), }); } - let balance = credits.balance.as_ref()?; - let display_balance = format_credit_balance(balance)?; + if !credits.has_credits { + return None; + } + let value = credits + .balance + .as_deref() + .and_then(format_credit_balance) + .map_or_else( + || "Available".to_string(), + |display_balance| format!("{display_balance} credits"), + ); Some(StatusRateLimitRow { label: "Credits".to_string(), - value: StatusRateLimitValue::Text(format!("{display_balance} credits")), + value: StatusRateLimitValue::Text(value), }) } @@ -392,6 +397,7 @@ fn format_credit_balance(raw: &str) -> Option { } if let Ok(value) = trimmed.parse::() + && value.is_finite() && value > 0.0 { let rounded = value.round() as i64; diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap index 48fa575d1f..c1636b6c15 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -19,5 +19,5 @@ expression: sanitized │ Context window: 100% left (2.2K used / 272K) │ │ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ │ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ -│ Credits: 38 credits │ +│ Credits: Available │ ╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 50ef2c5000..eedcfdfff2 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -288,6 +288,7 @@ async fn status_snapshot_includes_reasoning_details() { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -985,6 +986,7 @@ async fn status_snapshot_includes_monthly_limit() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1049,6 +1051,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() { remaining_percent: 68, resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400), }), + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1129,6 +1132,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1182,6 +1186,7 @@ async fn status_snapshot_shows_unlimited_credits() { balance: None, }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1233,6 +1238,7 @@ async fn status_snapshot_shows_positive_credits() { balance: Some("12.5".to_string()), }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1264,7 +1270,7 @@ async fn status_snapshot_shows_positive_credits() { } #[tokio::test] -async fn status_snapshot_hides_zero_credits() { +async fn status_snapshot_shows_available_credits_without_display_balance() { let temp_home = TempDir::new().expect("temp home"); let config = test_config(&temp_home).await; let account_display = test_status_account_display(); @@ -1273,47 +1279,58 @@ async fn status_snapshot_hides_zero_credits() { .with_ymd_and_hms(2024, 4, 5, 6, 7, 8) .single() .expect("timestamp"); - let snapshot = RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("0".to_string()), - }), - individual_limit: None, - plan_type: None, - rate_limit_reached_type: None, - }; - let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let model_slug = get_model_offline_for_tests(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); - let composite = new_status_output( - &config, - account_display.as_ref(), - Some(&token_info), - &usage, - &None, - /*thread_name*/ None, - /*forked_from*/ None, - Some(&rate_display), + for balance in [ None, - captured_at, - &model_slug, - /*collaboration_mode*/ None, - /*reasoning_effort_override*/ None, - ); - let rendered = render_lines(&composite.display_lines(/*width*/ 120)); - assert!( - rendered.iter().all(|line| !line.contains("Credits:")), - "expected no Credits line, got {rendered:?}" - ); + Some(String::new()), + Some("0".to_string()), + Some("not-a-number".to_string()), + Some("inf".to_string()), + ] { + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance, + }), + individual_limit: None, + spend_control_reached: None, + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let rendered = render_lines(&composite.display_lines(/*width*/ 120)); + assert!( + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Available")), + "expected Credits: Available line, got {rendered:?}" + ); + } } #[tokio::test] -async fn status_snapshot_hides_when_has_no_credits_flag() { +async fn status_snapshot_respects_unlimited_without_has_credits_flag() { let temp_home = TempDir::new().expect("temp home"); let config = test_config(&temp_home).await; let account_display = test_status_account_display(); @@ -1333,6 +1350,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { balance: None, }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1356,8 +1374,10 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { ); let rendered = render_lines(&composite.display_lines(/*width*/ 120)); assert!( - rendered.iter().all(|line| !line.contains("Credits:")), - "expected no Credits line when has_credits is false, got {rendered:?}" + rendered + .iter() + .any(|line| line.contains("Credits:") && line.contains("Unlimited")), + "expected Credits: Unlimited line, got {rendered:?}" ); } @@ -1440,6 +1460,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1611,6 +1632,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1680,9 +1702,10 @@ async fn status_snapshot_includes_credits_and_limits() { credits: Some(CreditsSnapshot { has_credits: true, unlimited: false, - balance: Some("37.5".to_string()), + balance: None, }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1738,6 +1761,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1796,6 +1820,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { secondary: None, credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1868,6 +1893,7 @@ async fn status_snapshot_shows_stale_limits_message() { }), credits: None, individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1940,6 +1966,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { balance: Some("80".to_string()), }), individual_limit: None, + spend_control_reached: None, plan_type: None, rate_limit_reached_type: None, };