Honor workspace spend controls in rate-limit handling (#33187)

## Why

Sparse and out-of-order rate-limit updates can omit account metadata or let an
older full read overwrite a newer workspace hard stop. Credit availability also
does not always include a displayable balance.

## What changed

- Propagate the backend's `spendControlReached` state through rate-limit
  snapshots and the app-server protocol, preserving it across sparse updates.
- Keep workspace hard-stop classifications for error routing and ignore stale
  full-read results after a newer hard-stop notification.
- Base TUI warnings and model-switch prompts on credit availability and hard-stop
  state, and show `Credits: Available` when a finite balance is hidden.

## Testing

Added coverage for spend-control mapping, snapshot merging, out-of-order reads,
workspace error routing, warning and prompt behavior, and credit status display.

GitOrigin-RevId: 18b09d493d5a620a7c079fb721ec50b38dd5b04c
This commit is contained in:
stefanstokic-oai
2026-07-14 22:43:26 +00:00
committed by copyberry
parent be0e0d791a
commit 4df8027a97
42 changed files with 1259 additions and 120 deletions
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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, };
@@ -532,6 +532,8 @@ pub struct RateLimitSnapshot {
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
/// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
pub spend_control_reached: Option<bool>,
pub plan_type: Option<PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -545,6 +547,7 @@ impl From<CoreRateLimitSnapshot> 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
+2 -1
View File
@@ -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.
@@ -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,
};
@@ -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
}
@@ -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,
},
+35 -8
View File
@@ -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<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
individual_limit: Option<SpendControlLimitSnapshot>,
spend_control: Option<codex_backend_openapi_models::models::SpendControlStatusDetails>,
plan_type: Option<AccountPlanType>,
rate_limit_reached_type: Option<RateLimitReachedType>,
) -> 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,
},
+9 -3
View File
@@ -86,12 +86,18 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&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
@@ -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 = [
+2
View File
@@ -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<RateLimitSnapshot> {
secondary,
credits,
individual_limit: None,
spend_control_reached: None,
plan_type: event.plan_type,
rate_limit_reached_type: None,
})
+6
View File
@@ -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,
})
+3
View File
@@ -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);
}
+26
View File
@@ -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)
);
}
+16 -3
View File
@@ -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
);
+13 -4
View File
@@ -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
);
@@ -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,
}
+1
View File
@@ -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,
}
+2
View File
@@ -2124,6 +2124,8 @@ pub struct RateLimitSnapshot {
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
/// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
pub spend_control_reached: Option<bool>,
pub plan_type: Option<crate::account::PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
+3
View File
@@ -577,6 +577,8 @@ pub(crate) struct App {
pending_primary_events: VecDeque<ThreadBufferedEvent>,
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(),
};
+14
View File
@@ -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;
+6 -1
View File
@@ -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,
});
});
}
+11 -2
View File
@@ -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,
+1
View File
@@ -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(),
}
+3
View File
@@ -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(),
},
+388
View File
@@ -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<RateLimitReachedType>,
spend_control_reached: Option<bool>,
) -> 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<AppEvent>,
) -> 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::<Vec<_>>()
.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(())
}
+1
View File
@@ -317,6 +317,7 @@ pub(crate) enum AppEvent {
/// Result of refreshing rate limits.
RateLimitsLoaded {
origin: RateLimitRefreshOrigin,
hard_stop_generation: u64,
result: Result<GetAccountRateLimitsResponse, String>,
},
+1
View File
@@ -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,
}
+1
View File
@@ -565,6 +565,7 @@ pub(crate) struct ChatWidget {
next_rate_limit_reset_request_id: u64,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
codex_spend_control_reached: Option<bool>,
rate_limit_warnings: RateLimitWarningState,
warning_display_state: WarningDisplayState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
@@ -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(),
+43 -14
View File
@@ -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<RateLimitSnapshot>) {
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::<f64>()
.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();
}
+1
View File
@@ -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
@@ -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,
}
@@ -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,
@@ -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,
}));
+16 -10
View File
@@ -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<StatusRateLimitRow> {
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<String> {
}
if let Ok(value) = trimmed.parse::<f64>()
&& value.is_finite()
&& value > 0.0
{
let rounded = value.round() as i64;
@@ -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 │
╰─────────────────────────────────────────────────────────────────────────────╯
+66 -39
View File
@@ -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,
};