mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Show actionable rate-limit banners in the TUI (#41742)
## What changed - Carry optional backend-owned banner and account identity data through `account/rateLimits/read`, filtering banners that do not match the authenticated account and user. - Render supported rate-limit notices above the composer with actions for usage, credits, resets, owner notifications, and plan management while preserving the existing fallback UI for absent or unsupported banners. - Refresh usage after limit errors, reject stale responses, and keep queued input paused until recovery completes. When instructed by the banner, switch to the first available fallback model without changing unrelated thread settings. ## Testing - Add protocol, banner parsing and rendering, CTA, identity filtering, refresh ordering, fallback-model, account-change, and queued-input recovery coverage. GitOrigin-RevId: e03d75a82e425deb87f4b120a71cf2286c0ef6cc
This commit is contained in:
committed by
copyberry
parent
94cbbddafc
commit
79b04f1ab5
+10
@@ -12163,6 +12163,13 @@
|
||||
"GetAccountRateLimitsResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"accountId": {
|
||||
"description": "Account associated with this usage snapshot, when supplied by the backend.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -12173,6 +12180,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitUpsell": {
|
||||
"description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged."
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+10
@@ -8174,6 +8174,13 @@
|
||||
"GetAccountRateLimitsResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"accountId": {
|
||||
"description": "Account associated with this usage snapshot, when supplied by the backend.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8184,6 +8191,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitUpsell": {
|
||||
"description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged."
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+10
@@ -277,6 +277,13 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"accountId": {
|
||||
"description": "Account associated with this usage snapshot, when supplied by the backend.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -287,6 +294,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitUpsell": {
|
||||
"description": "Optional backend-owned banner from the same usage read. Its nested keys retain the backend's snake_case contract; an absent banner leaves the client's existing UI unchanged."
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-1
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { JsonValue } from "../serde_json/JsonValue";
|
||||
import type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
|
||||
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
@@ -12,4 +13,13 @@ rateLimits: RateLimitSnapshot,
|
||||
/**
|
||||
* Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
*/
|
||||
rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, rateLimitResetCredits: RateLimitResetCreditsSummary | null, };
|
||||
rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, rateLimitResetCredits: RateLimitResetCreditsSummary | null,
|
||||
/**
|
||||
* Account associated with this usage snapshot, when supplied by the backend.
|
||||
*/
|
||||
accountId: string | null,
|
||||
/**
|
||||
* Optional backend-owned banner from the same usage read. Its nested keys retain the
|
||||
* backend's snake_case contract; an absent banner leaves the client's existing UI unchanged.
|
||||
*/
|
||||
rateLimitUpsell: JsonValue | null, };
|
||||
|
||||
@@ -317,6 +317,11 @@ pub struct GetAccountRateLimitsResponse {
|
||||
/// Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
/// Account associated with this usage snapshot, when supplied by the backend.
|
||||
pub account_id: Option<String>,
|
||||
/// Optional backend-owned banner from the same usage read. Its nested keys retain the
|
||||
/// backend's snake_case contract; an absent banner leaves the client's existing UI unchanged.
|
||||
pub rate_limit_upsell: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -2470,7 +2470,7 @@ 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`, `bedrockAccessKeys`, `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, 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/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 and backend-banner data are 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, or pass a valid thread UUID as `threadId` to read estimated credits, optional cost, and usage breakdowns for one thread using the app-server's active account. The optional `threadUsage` response field is absent on older servers and `null` when the billing route is unavailable.
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
@@ -2712,6 +2712,8 @@ Field notes:
|
||||
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
|
||||
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
|
||||
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
|
||||
- `accountId` identifies the account in the usage snapshot when the backend supplies it.
|
||||
- `rateLimitUpsell` carries the optional backend-owned `rate_limit_upsell` object from the same usage request, preserving its nested snake_case fields. The backend controls eligibility; clients do not evaluate an experiment or issue another request for it. A missing, null, or unsupported banner leaves the existing client UI in place. Banners whose account or user does not match the authenticated identity are omitted. Sparse notifications do not clear this snapshot-only field.
|
||||
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
|
||||
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
|
||||
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
|
||||
|
||||
@@ -1189,6 +1189,16 @@ impl AccountRequestProcessor {
|
||||
})
|
||||
});
|
||||
|
||||
// Match desktop's account readiness check before exposing account-bound CTA content.
|
||||
// Normal rate limits remain available when older backends omit identity or banner data.
|
||||
let rate_limit_upsell = response.rate_limit_upsell.filter(|_| {
|
||||
!auth.is_fedramp_account()
|
||||
&& response.account_id.is_some()
|
||||
&& response.account_id == auth.get_account_id()
|
||||
&& response.user_id.is_some()
|
||||
&& response.user_id == auth.get_chatgpt_user_id()
|
||||
});
|
||||
|
||||
Ok(GetAccountRateLimitsResponse {
|
||||
rate_limits: rate_limits.into(),
|
||||
rate_limits_by_limit_id: Some(
|
||||
@@ -1198,6 +1208,8 @@ impl AccountRequestProcessor {
|
||||
.collect(),
|
||||
),
|
||||
rate_limit_reset_credits,
|
||||
account_id: response.account_id,
|
||||
rate_limit_upsell,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ pub struct ChatGptIdTokenClaims {
|
||||
pub plan_type: Option<String>,
|
||||
pub chatgpt_user_id: Option<String>,
|
||||
pub chatgpt_account_id: Option<String>,
|
||||
pub chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
|
||||
impl ChatGptIdTokenClaims {
|
||||
@@ -127,6 +128,9 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
|
||||
if let Some(chatgpt_account_id) = &claims.chatgpt_account_id {
|
||||
auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id));
|
||||
}
|
||||
if claims.chatgpt_account_is_fedramp {
|
||||
auth_payload.insert("chatgpt_account_is_fedramp".to_string(), json!(true));
|
||||
}
|
||||
if !auth_payload.is_empty() {
|
||||
payload.insert(
|
||||
"https://api.openai.com/auth".to_string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
@@ -109,6 +110,7 @@ async fn get_account_rate_limits_returns_snapshot(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
@@ -133,7 +135,17 @@ async fn get_account_rate_limits_returns_snapshot(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
|
||||
.expect("parse second reset credit grant timestamp")
|
||||
.timestamp();
|
||||
let banner = json!({
|
||||
"banner_type": "selected_model_limit_reached",
|
||||
"title": "Usage limit reached",
|
||||
"description": "View your usage.",
|
||||
"ctas": [{"action": "view_usage", "label": "View usage"}]
|
||||
});
|
||||
let response_body = json!({
|
||||
"account_id": "account-123",
|
||||
"user_id": "user-123",
|
||||
"rate_limit_upsell": banner,
|
||||
|
||||
"plan_type": plan_type,
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
@@ -237,6 +249,8 @@ async fn get_account_rate_limits_returns_snapshot(
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
|
||||
|
||||
let expected = GetAccountRateLimitsResponse {
|
||||
account_id: Some("account-123".to_string()),
|
||||
rate_limit_upsell: Some(banner),
|
||||
rate_limits: RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
@@ -343,6 +357,85 @@ async fn get_account_rate_limits_returns_snapshot(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(Some("workspace-a"), Some("user-a"), /*restricted*/ false, /*permitted*/ true; "matching_identity")]
|
||||
#[test_case(Some("workspace-b"), Some("user-a"), /*restricted*/ false, /*permitted*/ false; "account_mismatch")]
|
||||
#[test_case(Some("workspace-a"), Some("user-b"), /*restricted*/ false, /*permitted*/ false; "user_mismatch")]
|
||||
#[test_case(/*account*/ None, Some("user-a"), /*restricted*/ false, /*permitted*/ false; "missing_account")]
|
||||
#[test_case(Some("workspace-a"), /*user*/ None, /*restricted*/ false, /*permitted*/ false; "missing_user")]
|
||||
#[test_case(Some("workspace-a"), Some("user-a"), /*restricted*/ true, /*permitted*/ false; "restricted_account")]
|
||||
#[tokio::test]
|
||||
async fn get_account_rate_limits_filters_banner_by_identity(
|
||||
account: Option<&str>,
|
||||
user: Option<&str>,
|
||||
restricted: bool,
|
||||
permitted: bool,
|
||||
) -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut claims = ChatGptIdTokenClaims::new()
|
||||
.plan_type("team")
|
||||
.chatgpt_user_id("user-a");
|
||||
claims.chatgpt_account_is_fedramp = restricted;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("workspace-a")
|
||||
.claims(claims),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
|
||||
let banner = json!({
|
||||
"banner_type": "selected_model_limit", "model_slug": "test-model-a",
|
||||
"presentation": "inline", "title": "Usage limit reached",
|
||||
"description": "Contact your owner.",
|
||||
"ctas": [{"action": "notify_owner", "label": "Notify owner"}]
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "workspace-a"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"account_id": account, "user_id": user, "plan_type": "team",
|
||||
"rate_limit": {"allowed": true, "limit_reached": false,
|
||||
"primary_window": {"used_percent": 42, "limit_window_seconds": 3600,
|
||||
"reset_after_seconds": 120, "reset_at": 2000000000}},
|
||||
"rate_limit_upsell": banner, "rate_limit_reset_credits": {"available_count": 0}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"available_count": 0, "credits": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let request_id = mcp.send_get_account_rate_limits_request().await?;
|
||||
let received: GetAccountRateLimitsResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
|
||||
let snapshot = json!({
|
||||
"limitId": "codex", "planType": "team",
|
||||
"primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 2000000000}
|
||||
});
|
||||
let expected: GetAccountRateLimitsResponse = serde_json::from_value(json!({
|
||||
"accountId": account, "rateLimitUpsell": if permitted { Some(banner) } else { None },
|
||||
"rateLimits": snapshot, "rateLimitsByLimitId": {"codex": snapshot},
|
||||
"rateLimitResetCredits": {"availableCount": 0, "credits": []}
|
||||
}))?;
|
||||
assert_eq!(received, expected);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -25,6 +25,9 @@ impl Client {
|
||||
Ok(RateLimitsWithResetCredits {
|
||||
rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits),
|
||||
rate_limit_reset_credits: payload.rate_limit_reset_credits,
|
||||
account_id: payload.account_id,
|
||||
user_id: payload.user_id,
|
||||
rate_limit_upsell: payload.rate_limit_upsell,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ pub struct RateLimitResetCreditDetails {
|
||||
pub struct RateLimitsWithResetCredits {
|
||||
pub rate_limits: Vec<RateLimitSnapshot>,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
pub account_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub rate_limit_upsell: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
@@ -52,6 +55,10 @@ pub(crate) struct RateLimitStatusWithResetCredits {
|
||||
#[serde(flatten)]
|
||||
pub rate_limits: RateLimitStatusPayload,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
pub account_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
// Preserve the backend-owned banner contract without making optional UI data break usage.
|
||||
pub rate_limit_upsell: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -211,6 +211,7 @@ pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
mod app_server_event_targets;
|
||||
mod app_server_events;
|
||||
pub(crate) mod app_server_requests;
|
||||
mod backend_banner_fallback;
|
||||
mod background_requests;
|
||||
mod config_persistence;
|
||||
mod connector_mentions;
|
||||
@@ -226,6 +227,7 @@ mod permission_shortcuts;
|
||||
mod pets;
|
||||
mod platform_actions;
|
||||
mod plugin_mentions;
|
||||
mod rate_limit_refresh;
|
||||
mod recap;
|
||||
mod replay_filter;
|
||||
mod resize_reflow;
|
||||
@@ -627,6 +629,7 @@ pub(crate) struct App {
|
||||
startup_pending_protected_request: bool,
|
||||
/// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives.
|
||||
rate_limit_hard_stop_generation: u64,
|
||||
rate_limit_refresh_state: rate_limit_refresh::RateLimitRefreshState,
|
||||
// Serialize plugin enablement writes per plugin so stale completions cannot
|
||||
// overwrite a newer toggle, even if the plugin is toggled from different
|
||||
// cwd contexts.
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::app_server_event_targets::server_notification_thread_target;
|
||||
use super::app_server_event_targets::server_request_thread_id;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::RateLimitRefreshOrigin;
|
||||
use crate::app_info::app_info_from_api;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::app_server_session::status_account_display_from_auth_mode;
|
||||
@@ -178,7 +179,7 @@ impl App {
|
||||
self.refresh_mcp_startup_expected_servers_from_config();
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(notification) => {
|
||||
if matches!(
|
||||
let workspace_hard_stop = matches!(
|
||||
notification.rate_limits.rate_limit_reached_type,
|
||||
Some(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted
|
||||
@@ -186,16 +187,24 @@ impl App {
|
||||
| RateLimitReachedType::WorkspaceOwnerUsageLimitReached
|
||||
| RateLimitReachedType::WorkspaceMemberUsageLimitReached
|
||||
)
|
||||
) || notification.rate_limits.spend_control_reached == Some(true)
|
||||
{
|
||||
) || notification.rate_limits.spend_control_reached
|
||||
== Some(true);
|
||||
if workspace_hard_stop {
|
||||
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());
|
||||
if workspace_hard_stop && self.chat_widget.has_chatgpt_account() {
|
||||
// Background inference may publish a hard stop without a foreground Error.
|
||||
self.refresh_rate_limits(app_server_client, RateLimitRefreshOrigin::Recovery);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ServerNotification::AccountUpdated(notification) => {
|
||||
self.rate_limit_hard_stop_generation =
|
||||
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
||||
self.rate_limit_refresh_state.invalidate_recovery();
|
||||
// Deferred terminal writes must never carry the previous account's billing into
|
||||
// the newly authenticated identity, even when both accounts share one thread.
|
||||
self.last_thread_usage_status_cell = None;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Apply an accepted account usage response to the currently selected task and model.
|
||||
|
||||
use super::App;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::service_tier_resolution;
|
||||
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn apply_backend_banner_fallback(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
) {
|
||||
let Some(thread_id) = self.active_thread_id else {
|
||||
return;
|
||||
};
|
||||
if Some(thread_id) != self.chat_widget.thread_id() {
|
||||
return;
|
||||
}
|
||||
let Some(target) = self.chat_widget.backend_banner_fallback() else {
|
||||
return;
|
||||
};
|
||||
let effort = self
|
||||
.chat_widget
|
||||
.current_reasoning_effort()
|
||||
.filter(|effort| {
|
||||
target
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|option| option.effort == *effort)
|
||||
})
|
||||
.unwrap_or(target.default_reasoning_effort);
|
||||
let mut mode = self.chat_widget.effective_collaboration_mode();
|
||||
mode.settings.model = target.model.clone();
|
||||
mode.settings.reasoning_effort = Some(effort.clone());
|
||||
// Automatic recovery must not apply the permission defaults used by manual model selection.
|
||||
let params = ThreadSettingsUpdateParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
model: Some(target.model.clone()),
|
||||
effort: Some(effort.clone()),
|
||||
collaboration_mode: Some(mode.clone()),
|
||||
service_tier: service_tier_resolution::service_tier_update_for_core(
|
||||
self.chat_widget.config_ref(),
|
||||
&target.model,
|
||||
&self.model_catalog.try_list_models().unwrap_or_default(),
|
||||
),
|
||||
..ThreadSettingsUpdateParams::default()
|
||||
};
|
||||
// Older remote servers can decline this method. Keep the existing recovery UI in that
|
||||
// case or on failure, rather than changing local selection before the task accepts it.
|
||||
// Event dispatch awaits this operation; a queued manual model selection runs afterward.
|
||||
if self.send_thread_settings_update(app_server, params).await {
|
||||
self.chat_widget.finish_backend_banner_fallback(mode);
|
||||
self.sync_active_thread_service_tier_to_cached_session()
|
||||
.await;
|
||||
let mut message = format!("Automatically switched to {}", target.model);
|
||||
if let Some(label) = Self::reasoning_label_for(&target.model, Some(&effort)) {
|
||||
message.push(' ');
|
||||
message.push_str(&label);
|
||||
}
|
||||
message.push_str(" due to usage limits.");
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,22 +73,33 @@ impl App {
|
||||
/// Spawns a background task to fetch account rate limits and deliver the
|
||||
/// result as a `RateLimitsLoaded` event.
|
||||
///
|
||||
/// The `origin` is forwarded to the completion handler so it can distinguish
|
||||
/// a startup prefetch (which updates cached snapshots and may surface a
|
||||
/// reset-credit notice) from a `/status`-triggered refresh (which must
|
||||
/// finalize the corresponding status card).
|
||||
/// Recovery requests are coalesced and bounded by the reset-request timeout. The origin
|
||||
/// also identifies command-specific completion work, such as finalizing a `/status` card,
|
||||
/// without confusing sparse inference notifications with authoritative usage responses.
|
||||
pub(super) fn refresh_rate_limits(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
origin: RateLimitRefreshOrigin,
|
||||
) {
|
||||
if matches!(
|
||||
origin,
|
||||
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. }
|
||||
) {
|
||||
self.chat_widget.hold_rate_limit_recovery();
|
||||
}
|
||||
let Some((request_id, hard_stop_generation)) = self
|
||||
.rate_limit_refresh_state
|
||||
.start(origin, &mut self.rate_limit_hard_stop_generation)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
RateLimitRefreshOrigin::ResetConsume { .. }
|
||||
RateLimitRefreshOrigin::Recovery
|
||||
| RateLimitRefreshOrigin::ResetConsume { .. }
|
||||
| RateLimitRefreshOrigin::ResetPicker { .. } => {
|
||||
tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request)
|
||||
.await
|
||||
@@ -102,6 +113,7 @@ impl App {
|
||||
}
|
||||
};
|
||||
app_event_tx.send(AppEvent::RateLimitsLoaded {
|
||||
request_id,
|
||||
origin,
|
||||
hard_stop_generation,
|
||||
result,
|
||||
@@ -208,6 +220,7 @@ impl App {
|
||||
pub(super) fn send_add_credits_nudge_email(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
request_id: Uuid,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
@@ -216,7 +229,7 @@ impl App {
|
||||
let result = send_add_credits_nudge_email(request_handle, credit_type)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { result });
|
||||
app_event_tx.send(AppEvent::AddCreditsNudgeEmailFinished { request_id, result });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain
|
||||
//! actions are delegated to focused app submodules so the central match remains the routing layer.
|
||||
|
||||
use super::rate_limit_refresh::RateLimitReadStatus;
|
||||
use super::rate_limit_refresh::RateLimitRefreshOutcome;
|
||||
use super::resize_reflow::trailing_run_start;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
@@ -1234,31 +1236,58 @@ impl App {
|
||||
self.clear_thread_goal(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::SendAddCreditsNudgeEmail { credit_type } => {
|
||||
if self
|
||||
if let Some(request_id) = self
|
||||
.chat_widget
|
||||
.start_add_credits_nudge_email_request(credit_type)
|
||||
{
|
||||
self.send_add_credits_nudge_email(app_server, credit_type);
|
||||
self.send_add_credits_nudge_email(app_server, request_id, credit_type);
|
||||
}
|
||||
}
|
||||
AppEvent::AddCreditsNudgeEmailFinished { result } => {
|
||||
AppEvent::AddCreditsNudgeEmailFinished { request_id, result } => {
|
||||
self.chat_widget
|
||||
.finish_add_credits_nudge_email_request(result);
|
||||
.finish_add_credits_nudge_email_request(request_id, result);
|
||||
}
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id,
|
||||
origin,
|
||||
hard_stop_generation,
|
||||
result,
|
||||
} => match result {
|
||||
} => {
|
||||
let accepted = match self.rate_limit_refresh_state.finish(
|
||||
request_id,
|
||||
hard_stop_generation,
|
||||
self.rate_limit_hard_stop_generation,
|
||||
if result.is_ok() {
|
||||
RateLimitReadStatus::Succeeded
|
||||
} else {
|
||||
RateLimitReadStatus::Failed
|
||||
},
|
||||
) {
|
||||
RateLimitRefreshOutcome::Apply => true,
|
||||
RateLimitRefreshOutcome::Ignore => false,
|
||||
RateLimitRefreshOutcome::RefreshRecovery => {
|
||||
// Start in this account's event turn; a queued refresh could cross an account change.
|
||||
self.refresh_rate_limits(app_server, RateLimitRefreshOrigin::Recovery);
|
||||
false
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
|
||||
let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation
|
||||
let snapshots = if accepted
|
||||
{
|
||||
self.chat_widget.update_backend_banner(&response);
|
||||
self.apply_backend_banner_fallback(app_server).await;
|
||||
app_server_rate_limit_snapshots(response)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
match origin {
|
||||
RateLimitRefreshOrigin::Recovery => {
|
||||
for snapshot in snapshots {
|
||||
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
}
|
||||
RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id,
|
||||
} => {
|
||||
@@ -1312,8 +1341,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// A failed read is not authoritative recovery. Keep the last valid banner.
|
||||
tracing::warn!("account/rateLimits/read failed during TUI refresh: {err}");
|
||||
match origin {
|
||||
RateLimitRefreshOrigin::Recovery => {},
|
||||
RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id,
|
||||
} => {
|
||||
@@ -1350,6 +1381,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
origin,
|
||||
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. }
|
||||
) && !self.rate_limit_refresh_state.has_pending_recovery()
|
||||
{
|
||||
self.chat_widget.finish_rate_limit_recovery();
|
||||
}
|
||||
},
|
||||
AppEvent::OpenTokenActivity => {
|
||||
self.chat_widget
|
||||
@@ -1412,6 +1451,11 @@ impl App {
|
||||
credit_id,
|
||||
result,
|
||||
) {
|
||||
// Reads started before redemption must not restore the pre-reset banner.
|
||||
self.rate_limit_hard_stop_generation =
|
||||
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
||||
self.rate_limit_refresh_state.invalidate_recovery();
|
||||
self.chat_widget.clear_backend_banner();
|
||||
self.refresh_rate_limits(
|
||||
app_server,
|
||||
RateLimitRefreshOrigin::ResetConsume { request_id },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Orders full usage reads and coalesces recovery refreshes within an account/limit generation.
|
||||
|
||||
use crate::app_event::RateLimitRefreshOrigin;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RateLimitRefreshState {
|
||||
next_id: u64,
|
||||
applied_id: u64,
|
||||
recovery: Option<PendingRecovery>,
|
||||
}
|
||||
|
||||
struct PendingRecovery {
|
||||
request: (u64, u64),
|
||||
origin: RateLimitRefreshOrigin,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum RateLimitRefreshOutcome {
|
||||
Apply,
|
||||
Ignore,
|
||||
RefreshRecovery,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub(super) enum RateLimitReadStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl RateLimitRefreshState {
|
||||
pub(super) fn has_pending_recovery(&self) -> bool {
|
||||
self.recovery.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn start(
|
||||
&mut self,
|
||||
origin: RateLimitRefreshOrigin,
|
||||
generation: &mut u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
if matches!(origin, RateLimitRefreshOrigin::Recovery) {
|
||||
// A new hard error supersedes a post-reset read that started before the error.
|
||||
if self
|
||||
.recovery
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.origin == RateLimitRefreshOrigin::Recovery)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A hard error invalidates even an older read without a preceding rolling notification.
|
||||
*generation = generation.wrapping_add(1);
|
||||
}
|
||||
self.next_id = self.next_id.wrapping_add(1);
|
||||
let request = (self.next_id, *generation);
|
||||
// The post-reset read takes over the hold from the invalidated recovery request.
|
||||
if matches!(
|
||||
origin,
|
||||
RateLimitRefreshOrigin::Recovery | RateLimitRefreshOrigin::ResetConsume { .. }
|
||||
) {
|
||||
self.recovery = Some(PendingRecovery { request, origin });
|
||||
}
|
||||
Some(request)
|
||||
}
|
||||
|
||||
// Account changes and successful resets invalidate the need as well as the old response.
|
||||
pub(super) fn invalidate_recovery(&mut self) {
|
||||
self.recovery = None;
|
||||
}
|
||||
|
||||
pub(super) fn finish(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
generation: u64,
|
||||
current_generation: u64,
|
||||
status: RateLimitReadStatus,
|
||||
) -> RateLimitRefreshOutcome {
|
||||
if self
|
||||
.recovery
|
||||
.as_ref()
|
||||
.is_some_and(|pending| pending.request == (request_id, generation))
|
||||
{
|
||||
self.recovery = None;
|
||||
// A rolling hard stop can arrive after the error, without another error notification.
|
||||
if generation != current_generation {
|
||||
return RateLimitRefreshOutcome::RefreshRecovery;
|
||||
}
|
||||
}
|
||||
if generation != current_generation
|
||||
|| request_id < self.applied_id
|
||||
|| status == RateLimitReadStatus::Failed
|
||||
{
|
||||
return RateLimitRefreshOutcome::Ignore;
|
||||
}
|
||||
self.applied_id = request_id;
|
||||
RateLimitRefreshOutcome::Apply
|
||||
}
|
||||
}
|
||||
@@ -449,6 +449,7 @@ impl App {
|
||||
chat_widget.last_terminal_title = previous_terminal_title;
|
||||
}
|
||||
chat_widget.remote_connection = self.chat_widget.remote_connection.clone();
|
||||
chat_widget.inherit_backend_banner_state(&mut self.chat_widget);
|
||||
for (thread_id, entry) in self.agent_navigation.ordered_threads() {
|
||||
chat_widget.set_collab_agent_metadata(
|
||||
thread_id,
|
||||
@@ -706,8 +707,15 @@ impl App {
|
||||
if started.blocks_direct_input {
|
||||
self.mark_primary_thread_parent_owned(thread_id);
|
||||
}
|
||||
// A full usage read can finish before thread/start. Apply its cached fallback
|
||||
// after attachment but before the initial prompt or queued draft is submitted.
|
||||
let recovery_was_pending = self.chat_widget.hold_rate_limit_recovery();
|
||||
self.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
self.apply_backend_banner_fallback(app_server).await;
|
||||
if !recovery_was_pending {
|
||||
self.chat_widget.finish_rate_limit_recovery();
|
||||
}
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -537,6 +537,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
startup_protected_input_boundary: true,
|
||||
startup_pending_protected_request: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
rate_limit_refresh_state: Default::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
recap: recap::RecapState::default(),
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
startup_protected_input_boundary: false,
|
||||
startup_pending_protected_request: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
rate_limit_refresh_state: Default::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
recap: recap::RecapState::default(),
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
#[path = "tests/advanced_reasoning_tests.rs"]
|
||||
mod advanced_reasoning_tests;
|
||||
#[path = "tests/backend_banner_fallback_tests.rs"]
|
||||
mod backend_banner_fallback_tests;
|
||||
#[path = "tests/backend_banner_recovery_tests.rs"]
|
||||
mod backend_banner_recovery_tests;
|
||||
#[path = "tests/backend_banner_startup_tests.rs"]
|
||||
mod backend_banner_startup_tests;
|
||||
#[path = "tests/background_exit_tests.rs"]
|
||||
mod background_exit_tests;
|
||||
#[path = "tests/connector_policy.rs"]
|
||||
@@ -5470,6 +5476,7 @@ async fn make_test_app() -> App {
|
||||
startup_protected_input_boundary: false,
|
||||
startup_pending_protected_request: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
rate_limit_refresh_state: Default::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
recap: recap::RecapState::default(),
|
||||
@@ -5551,6 +5558,7 @@ async fn make_test_app_with_channels() -> (
|
||||
startup_protected_input_boundary: false,
|
||||
startup_pending_protected_request: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
rate_limit_refresh_state: Default::default(),
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
recap: recap::RecapState::default(),
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn fallback_response() -> GetAccountRateLimitsResponse {
|
||||
serde_json::from_value(json!({
|
||||
"accountId": "workspace-a", "rateLimits": {},
|
||||
"rateLimitUpsell": {
|
||||
"banner_type": "model_recovery", "model_slug": "gpt-5.4",
|
||||
"blocked_model_slug": "gpt-5.4",
|
||||
"fallback_model_slugs": ["unavailable-model", "gpt-5.4", "gpt-5.2"],
|
||||
"title": "Selected model usage exhausted",
|
||||
"description": "Add credits to use this model again.",
|
||||
"presentation": "dismissible",
|
||||
"ctas": [{"action": "view_usage", "label": "View usage"}]
|
||||
}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn configure_fallback_model(app: &mut App) {
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
set_fast_mode_test_catalog(&mut app.chat_widget);
|
||||
app.model_catalog = app.chat_widget.model_catalog();
|
||||
app.chat_widget.set_model("gpt-5.4");
|
||||
}
|
||||
|
||||
async fn start_fallback_thread(
|
||||
app: &mut App,
|
||||
) -> Result<session_lifecycle_requests::RecordingAppServer> {
|
||||
let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server(
|
||||
&app.config,
|
||||
/*blocked_thread_list*/ None,
|
||||
/*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
let started = server.start_thread(&app.config).await?;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
configure_fallback_model(app);
|
||||
Ok((server, requests, proxy))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_updates_task_settings_and_keeps_notice() -> Result<()> {
|
||||
for mode_kind in [ModeKind::Default, ModeKind::Plan] {
|
||||
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
|
||||
let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?;
|
||||
let thread_id = app.active_thread_id.expect("active fallback thread");
|
||||
app.chat_widget
|
||||
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
||||
if mode_kind == ModeKind::Plan {
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
app.chat_widget
|
||||
.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
}
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
|
||||
app.chat_widget
|
||||
.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
|
||||
let mut expected_mode = app.chat_widget.effective_collaboration_mode();
|
||||
assert_eq!(expected_mode.mode, mode_kind);
|
||||
expected_mode.settings.model = "gpt-5.2".into();
|
||||
expected_mode.settings.reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
||||
let default_model = app.config.model.clone();
|
||||
let default_plan_effort = app.config.plan_mode_reasoning_effort.clone();
|
||||
let config_path = app.config.codex_home.join("config.toml");
|
||||
let saved_config = std::fs::read(&config_path).ok();
|
||||
while events.try_recv().is_ok() {}
|
||||
while ops.try_recv().is_ok() {}
|
||||
requests.lock().unwrap().clear();
|
||||
let response = fallback_response();
|
||||
let generation = app.rate_limit_hard_stop_generation;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 1,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 },
|
||||
hard_stop_generation: generation,
|
||||
result: Ok(response.clone()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let actual_mode = app.chat_widget.effective_collaboration_mode();
|
||||
assert_eq!(actual_mode, expected_mode);
|
||||
assert_eq!(app.chat_widget.current_service_tier(), None);
|
||||
let sent = requests.lock().unwrap().clone();
|
||||
let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect();
|
||||
assert_eq!(methods, ["thread/settings/update"]);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ThreadSettingsUpdateParams>(sent[0].params.clone().unwrap())?,
|
||||
ThreadSettingsUpdateParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
model: Some("gpt-5.2".into()),
|
||||
effort: Some(ReasoningEffortConfig::Medium),
|
||||
collaboration_mode: Some(expected_mode.clone()),
|
||||
service_tier: Some(Some("default".into())),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72);
|
||||
assert!(rendered.contains("Selected model usage exhausted"));
|
||||
if mode_kind == ModeKind::Default {
|
||||
insta::assert_snapshot!(
|
||||
"backend_banner_after_fallback",
|
||||
normalize_snapshot_paths(rendered)
|
||||
);
|
||||
}
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert!(queued.iter().any(|event| matches!(event, AppEvent::OpenUrlInBrowser { url } if url == "https://chatgpt.com/codex/settings/usage")));
|
||||
let notices = queued
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
Some(lines_to_single_string(&cell.display_lines(/*width*/ 80)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(notices.len(), 1);
|
||||
insta::assert_snapshot!("backend_banner_fallback_notice", notices[0]);
|
||||
assert!(!queued.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::PersistModelSelection { .. } | AppEvent::CodexOp(_)
|
||||
)));
|
||||
assert!(ops.try_recv().is_err(), "switch must not replay a turn");
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 72)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
let mut healthy = response;
|
||||
healthy.rate_limit_upsell = None;
|
||||
app.chat_widget.update_backend_banner(&healthy);
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.2");
|
||||
assert_eq!(requests.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::InsertHistoryCell(_)))
|
||||
);
|
||||
assert_eq!(app.config.model, default_model);
|
||||
assert_eq!(app.config.plan_mode_reasoning_effort, default_plan_effort);
|
||||
assert_eq!(std::fs::read(&config_path).ok(), saved_config);
|
||||
if mode_kind == ModeKind::Plan {
|
||||
let chat = &mut app.chat_widget;
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
}
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer(UserMessage::from("continue"));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_matches!(next_user_turn_op(&mut ops), AppCommand::UserTurn { model, collaboration_mode: Some(mode), .. }
|
||||
if model == "gpt-5.2" && mode == expected_mode);
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_uses_current_task_and_accepted_generation() -> Result<()> {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?;
|
||||
let read_generation = app.rate_limit_hard_stop_generation;
|
||||
let second = server.start_thread(&app.config).await?;
|
||||
let active_id = second.session.thread_id;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui,
|
||||
app.config.clone(),
|
||||
/*initial_user_message*/ None,
|
||||
);
|
||||
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
app.store_active_thread_receiver().await;
|
||||
app.active_thread_id = None;
|
||||
app.enqueue_primary_thread_session(second.session, second.turns)
|
||||
.await?;
|
||||
configure_fallback_model(&mut app);
|
||||
requests.lock().unwrap().clear();
|
||||
app.rate_limit_hard_stop_generation = read_generation.wrapping_add(1);
|
||||
for generation in [read_generation, app.rate_limit_hard_stop_generation] {
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 1,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 },
|
||||
hard_stop_generation: generation,
|
||||
result: Ok(fallback_response()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if generation == read_generation {
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
|
||||
assert!(requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
let sent = requests.lock().unwrap().clone();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(
|
||||
sent[0].params.as_ref().unwrap()["threadId"],
|
||||
active_id.to_string()
|
||||
);
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.2");
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_keeps_existing_recovery_without_candidate() -> Result<()> {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?;
|
||||
requests.lock().unwrap().clear();
|
||||
for (replacement, expect_legacy_banner) in [
|
||||
(json!([]), true),
|
||||
(json!(["unavailable-model"]), false),
|
||||
(json!(["gpt-5.4"]), false),
|
||||
] {
|
||||
let mut response = fallback_response();
|
||||
response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = replacement;
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72);
|
||||
assert_eq!(rendered.contains("View usage"), expect_legacy_banner);
|
||||
if !expect_legacy_banner {
|
||||
insta::assert_snapshot!(
|
||||
"backend_banner_fallback_unavailable",
|
||||
normalize_snapshot_paths(rendered)
|
||||
);
|
||||
}
|
||||
}
|
||||
app.chat_widget.set_model("gpt-5.2");
|
||||
app.chat_widget.update_backend_banner(&fallback_response());
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.2");
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 72)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
assert!(requests.lock().unwrap().is_empty());
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_selection() -> Result<()>
|
||||
{
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
for error_code in [Some(-32601), Some(-32603), None] {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
let id = ThreadId::new();
|
||||
app.active_thread_id = Some(id);
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf()));
|
||||
configure_fallback_model(&mut app);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
||||
let codex_home = app.config.codex_home.display().to_string();
|
||||
let manual_selection_tx = app.app_event_tx.clone();
|
||||
let fake = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut socket = tokio_tungstenite::accept_async(stream).await?;
|
||||
let mut settings_requests = 0;
|
||||
while let Some(frame) = socket.next().await {
|
||||
let Message::Text(text) = frame? else {
|
||||
continue;
|
||||
};
|
||||
let request: serde_json::Value = serde_json::from_str(&text)?;
|
||||
if request["id"].is_null() {
|
||||
continue;
|
||||
}
|
||||
let response = if request["method"] == "initialize" {
|
||||
json!({"id": request["id"], "result": {"userAgent": "test", "codexHome": codex_home}})
|
||||
} else {
|
||||
assert_eq!(request["method"], "thread/settings/update");
|
||||
settings_requests += 1;
|
||||
if let Some(error_code) = error_code {
|
||||
json!({"id": request["id"], "error": {"code": error_code, "message": "settings unavailable"}})
|
||||
} else {
|
||||
if settings_requests == 1 {
|
||||
// A manual selection arriving while the RPC is pending must win afterward.
|
||||
manual_selection_tx.send(AppEvent::UpdateModel("gpt-5.4".into()));
|
||||
}
|
||||
json!({"id": request["id"], "result": {}})
|
||||
}
|
||||
};
|
||||
socket
|
||||
.send(Message::Text(response.to_string().into()))
|
||||
.await?;
|
||||
}
|
||||
Result::<()>::Ok(())
|
||||
});
|
||||
let client = crate::connect_remote_app_server(crate::RemoteAppServerEndpoint::WebSocket {
|
||||
websocket_url,
|
||||
auth_token: None,
|
||||
})
|
||||
.await?;
|
||||
let mut server = AppServerSession::new(
|
||||
client,
|
||||
crate::app_server_session::ThreadParamsMode::Embedded,
|
||||
);
|
||||
while events.try_recv().is_ok() {}
|
||||
app.chat_widget.update_backend_banner(&fallback_response());
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::<Vec<_>>();
|
||||
let switch_notices = queued
|
||||
.iter()
|
||||
.filter(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 80))
|
||||
.contains("Automatically switched")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count();
|
||||
assert_eq!(switch_notices, usize::from(error_code.is_none()));
|
||||
if error_code.is_none() {
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.2");
|
||||
let manual_selection = queued
|
||||
.into_iter()
|
||||
.find(|event| matches!(event, AppEvent::UpdateModel(_)))
|
||||
.unwrap();
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_event(&mut tui, &mut server, manual_selection)
|
||||
.await?;
|
||||
}
|
||||
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
|
||||
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 72).contains("View usage"));
|
||||
server.shutdown().await?;
|
||||
fake.await??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_preserves_permissions_for_first_eligible_cyber_model() -> Result<()>
|
||||
{
|
||||
let mut app = make_test_app().await;
|
||||
assert!(
|
||||
app.apply_permission_profile_selection(PermissionProfileSelection {
|
||||
profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.into(),
|
||||
approval_policy: Some(AskForApproval::UnlessTrusted),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
display_label: "Read Only".into(),
|
||||
})
|
||||
.await
|
||||
);
|
||||
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let started = server.start_thread(&app.config).await?;
|
||||
let thread_id = started.session.thread_id;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
configure_fallback_model(&mut app);
|
||||
let mut models = app.model_catalog.try_list_models()?;
|
||||
let mut cyber_model = models
|
||||
.iter()
|
||||
.find(|model| model.model == "gpt-5.2")
|
||||
.unwrap()
|
||||
.clone();
|
||||
// Like the manual-selection tests, classify a fixture model through catalog metadata.
|
||||
cyber_model.model = "cyber-test-model".into();
|
||||
cyber_model.model_specialty = Some(MODEL_SPECIALTY_CYBER.into());
|
||||
models.push(cyber_model.clone());
|
||||
app.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui,
|
||||
app.config.clone(),
|
||||
/*initial_user_message*/ None,
|
||||
);
|
||||
init.model_catalog = app.model_catalog.clone();
|
||||
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
app.chat_widget
|
||||
.handle_thread_session(app.primary_session_configured.clone().unwrap());
|
||||
app.chat_widget.set_model("gpt-5.4");
|
||||
app.chat_widget
|
||||
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
||||
let mut response = fallback_response();
|
||||
response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] =
|
||||
json!(["unavailable-model", "gpt-5.4", cyber_model.model, "gpt-5.2"]);
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
app.apply_backend_banner_fallback(&mut server).await;
|
||||
assert_eq!(app.chat_widget.current_model(), cyber_model.model);
|
||||
let settings = next_thread_settings_updated(&mut server, thread_id)
|
||||
.await
|
||||
.thread_settings;
|
||||
assert_eq!(
|
||||
(
|
||||
settings.model,
|
||||
settings.effort,
|
||||
settings.approval_policy,
|
||||
settings.approvals_reviewer.to_core(),
|
||||
settings.active_permission_profile.unwrap().id
|
||||
),
|
||||
(
|
||||
cyber_model.model,
|
||||
Some(ReasoningEffortConfig::Medium),
|
||||
AskForApproval::UnlessTrusted,
|
||||
ApprovalsReviewer::User,
|
||||
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()
|
||||
),
|
||||
);
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use super::*;
|
||||
use crate::app::rate_limit_refresh::RateLimitReadStatus;
|
||||
use crate::app::rate_limit_refresh::RateLimitRefreshOutcome;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::CodexErrorInfo;
|
||||
use codex_app_server_protocol::ErrorNotification;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn response_with_banner() -> GetAccountRateLimitsResponse {
|
||||
serde_json::from_value(json!({
|
||||
"accountId":"workspace-a", "rateLimitUpsell":{
|
||||
"banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline",
|
||||
"title":"Selected model usage exhausted", "description":"Switch to another model or contact your owner.",
|
||||
"ctas":[{"action":"notify_owner","label":"Notify owner"}]
|
||||
},
|
||||
"rateLimits":{}, "rateLimitsByLimitId":null,"rateLimitResetCredits":null
|
||||
})).unwrap()
|
||||
}
|
||||
|
||||
async fn next_rate_limits_loaded(
|
||||
events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Result<AppEvent> {
|
||||
Ok(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
let event = events.recv().await.expect("app event channel");
|
||||
if matches!(event, AppEvent::RateLimitsLoaded { .. }) {
|
||||
break event;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_limit_error_refreshes_again_after_intervening_rolling_hard_stop()
|
||||
-> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let home = tempdir()?;
|
||||
write_chatgpt_auth(
|
||||
home.path(),
|
||||
ChatGptAuthFixture::new("local-test-token")
|
||||
.account_id("workspace-a")
|
||||
.chatgpt_user_id("user-a")
|
||||
.plan_type("team"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write synthetic auth");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n",
|
||||
server.uri()
|
||||
),
|
||||
)?;
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
app.config.codex_home = home.path().to_path_buf().abs();
|
||||
app.config.chatgpt_base_url = server.uri();
|
||||
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs());
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
app.chat_widget.set_model("test-model-a");
|
||||
let mut healthy = response_with_banner();
|
||||
healthy.rate_limit_upsell = None;
|
||||
app.chat_widget.update_backend_banner(&healthy);
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(std::time::Duration::from_millis(/*millis*/ 100))
|
||||
.set_body_json(json!({
|
||||
"account_id":"workspace-a","user_id":"user-a","plan_type":"team",
|
||||
"rate_limit":{"allowed":true,"limit_reached":false},
|
||||
"rate_limit_upsell":response_with_banner().rate_limit_upsell,
|
||||
"rate_limit_reset_credits":{"available_count":0}
|
||||
})),
|
||||
)
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!({"available_count":0,"credits":[]})),
|
||||
)
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
while events.try_recv().is_ok() {}
|
||||
app.chat_widget.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
misalignment: None,
|
||||
message: "credits exhausted".into(),
|
||||
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: "thread-a".into(),
|
||||
turn_id: "turn-a".into(),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
let recovery = loop {
|
||||
let event = events
|
||||
.try_recv()
|
||||
.expect("limit error should request recovery without a command");
|
||||
if matches!(
|
||||
event,
|
||||
AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::Recovery
|
||||
}
|
||||
) {
|
||||
break event;
|
||||
}
|
||||
};
|
||||
app.handle_event(&mut tui, &mut session, recovery).await?;
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RefreshRateLimits {
|
||||
origin: RateLimitRefreshOrigin::Recovery,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut rolling = response_with_banner().rate_limits;
|
||||
rolling.rate_limit_reached_type =
|
||||
Some(codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
app.handle_app_server_event(
|
||||
&session,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||||
ServerNotification::AccountRateLimitsUpdated(
|
||||
codex_app_server_protocol::AccountRateLimitsUpdatedNotification {
|
||||
rate_limits: rolling.clone(),
|
||||
},
|
||||
),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let loaded = next_rate_limits_loaded(&mut events).await?;
|
||||
assert!(
|
||||
matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. }),
|
||||
"{loaded:?}"
|
||||
);
|
||||
app.handle_event(&mut tui, &mut session, loaded).await?;
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
// No second inference Error or explicit refresh: the obsolete read starts its own replacement.
|
||||
let loaded = next_rate_limits_loaded(&mut events).await?;
|
||||
app.handle_event(&mut tui, &mut session, loaded).await?;
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90);
|
||||
assert!(rendered.contains("Selected model usage exhausted"));
|
||||
assert!(rendered.contains("Notify owner"));
|
||||
// A background operation can report the same hard stop after the full response omitted
|
||||
// aggregate reached_type: included usage for other models is still available.
|
||||
app.handle_app_server_event(
|
||||
&session,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||||
ServerNotification::AccountRateLimitsUpdated(
|
||||
codex_app_server_protocol::AccountRateLimitsUpdatedNotification {
|
||||
rate_limits: rolling,
|
||||
},
|
||||
),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
let loaded = next_rate_limits_loaded(&mut events).await?;
|
||||
app.handle_event(&mut tui, &mut session, loaded).await?;
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
app.chat_widget.set_model("test-model-b");
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
session.shutdown().await?;
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_banner_recovery_follows_rolling_invalidation_without_another_error() {
|
||||
let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default();
|
||||
let mut epoch = 0;
|
||||
let first = refresh
|
||||
.start(RateLimitRefreshOrigin::Recovery, &mut epoch)
|
||||
.unwrap();
|
||||
epoch += 1;
|
||||
assert_eq!(
|
||||
refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded),
|
||||
RateLimitRefreshOutcome::RefreshRecovery
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_rolling_only_recovery_holds_new_input() -> Result<()> {
|
||||
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
|
||||
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
ThreadId::new(),
|
||||
app.config.cwd.to_path_buf(),
|
||||
));
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
app.chat_widget.set_model("test-model-a");
|
||||
let mut rolling = response_with_banner().rate_limits;
|
||||
rolling.spend_control_reached = Some(true);
|
||||
app.handle_app_server_event(
|
||||
&session,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||||
ServerNotification::AccountRateLimitsUpdated(
|
||||
codex_app_server_protocol::AccountRateLimitsUpdatedNotification {
|
||||
rate_limits: rolling,
|
||||
},
|
||||
),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
app.chat_widget.apply_external_edit("follow-up".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["follow-up"]
|
||||
);
|
||||
assert!(
|
||||
!std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
|
||||
);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 1,
|
||||
origin: RateLimitRefreshOrigin::Recovery,
|
||||
hard_stop_generation: app.rate_limit_hard_stop_generation,
|
||||
result: Ok(response_with_banner()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. }
|
||||
if items == vec![UserInput::Text { text: "follow-up".into(), text_elements: Vec::new() }])
|
||||
);
|
||||
session.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_account_changes_invalidate_pending_recovery() -> Result<()> {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget.set_model("test-model-a");
|
||||
let current = response_with_banner();
|
||||
app.chat_widget.update_backend_banner(¤t);
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
let pending = app
|
||||
.rate_limit_refresh_state
|
||||
.start(
|
||||
RateLimitRefreshOrigin::Recovery,
|
||||
&mut app.rate_limit_hard_stop_generation,
|
||||
)
|
||||
.unwrap();
|
||||
app.handle_app_server_event(
|
||||
&session,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||||
ServerNotification::AccountUpdated(
|
||||
codex_app_server_protocol::AccountUpdatedNotification {
|
||||
auth_mode: None,
|
||||
plan_type: None,
|
||||
},
|
||||
),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: pending.0,
|
||||
origin: RateLimitRefreshOrigin::Recovery,
|
||||
hard_stop_generation: pending.1,
|
||||
result: Ok(current),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
assert!(
|
||||
app.rate_limit_refresh_state
|
||||
.start(
|
||||
RateLimitRefreshOrigin::Recovery,
|
||||
&mut app.rate_limit_hard_stop_generation
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
session.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_reset_redemption_rejects_pre_reset_content() -> Result<()> {
|
||||
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
|
||||
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
ThreadId::new(),
|
||||
app.config.cwd.to_path_buf(),
|
||||
));
|
||||
app.chat_widget.set_model("test-model-a");
|
||||
let banner = response_with_banner();
|
||||
app.chat_widget.update_backend_banner(&banner);
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
let pending = app
|
||||
.rate_limit_refresh_state
|
||||
.start(
|
||||
RateLimitRefreshOrigin::Recovery,
|
||||
&mut app.rate_limit_hard_stop_generation,
|
||||
)
|
||||
.unwrap();
|
||||
app.chat_widget.hold_rate_limit_recovery();
|
||||
app.chat_widget.apply_external_edit("after reset".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["after reset"]
|
||||
);
|
||||
let request_id = app.chat_widget.show_rate_limit_reset_consuming_popup();
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitResetCreditConsumed {
|
||||
request_id,
|
||||
idempotency_key: "mock-reset".into(),
|
||||
credit_id: None,
|
||||
result: Ok(
|
||||
codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse {
|
||||
outcome:
|
||||
codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut recovered = banner.clone();
|
||||
recovered.rate_limit_upsell = None;
|
||||
recovered.rate_limit_reset_credits =
|
||||
Some(codex_app_server_protocol::RateLimitResetCreditsSummary {
|
||||
available_count: 0,
|
||||
credits: None,
|
||||
});
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 2,
|
||||
origin: RateLimitRefreshOrigin::ResetConsume { request_id },
|
||||
hard_stop_generation: app.rate_limit_hard_stop_generation,
|
||||
result: Ok(recovered),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. }
|
||||
if items == vec![UserInput::Text { text: "after reset".into(), text_elements: Vec::new() }])
|
||||
);
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: pending.0,
|
||||
origin: RateLimitRefreshOrigin::Recovery,
|
||||
hard_stop_generation: pending.1,
|
||||
result: Ok(banner),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
assert!(
|
||||
app.rate_limit_refresh_state
|
||||
.start(
|
||||
RateLimitRefreshOrigin::Recovery,
|
||||
&mut app.rate_limit_hard_stop_generation
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
session.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_banner_post_reset_read_preserves_newer_recovery_hold() {
|
||||
let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default();
|
||||
let mut epoch = 0;
|
||||
let old = refresh
|
||||
.start(RateLimitRefreshOrigin::Recovery, &mut epoch)
|
||||
.unwrap();
|
||||
epoch += 1;
|
||||
refresh.invalidate_recovery();
|
||||
let reset = refresh
|
||||
.start(
|
||||
RateLimitRefreshOrigin::ResetConsume { request_id: 1 },
|
||||
&mut epoch,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refresh.finish(old.0, old.1, epoch, RateLimitReadStatus::Succeeded),
|
||||
RateLimitRefreshOutcome::Ignore
|
||||
);
|
||||
assert!(refresh.has_pending_recovery());
|
||||
let newer = refresh
|
||||
.start(RateLimitRefreshOrigin::Recovery, &mut epoch)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refresh.finish(reset.0, reset.1, epoch, RateLimitReadStatus::Succeeded),
|
||||
RateLimitRefreshOutcome::Ignore
|
||||
);
|
||||
assert!(refresh.has_pending_recovery());
|
||||
assert_eq!(
|
||||
refresh.finish(newer.0, newer.1, epoch, RateLimitReadStatus::Failed),
|
||||
RateLimitRefreshOutcome::Ignore
|
||||
);
|
||||
assert!(!refresh.has_pending_recovery());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_banner_recovery_coalesces_until_newer_epoch_can_be_read() {
|
||||
let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default();
|
||||
let mut epoch = 0;
|
||||
let first = refresh
|
||||
.start(RateLimitRefreshOrigin::Recovery, &mut epoch)
|
||||
.unwrap();
|
||||
epoch += 1; // A newer rolling hard stop makes the in-flight response obsolete.
|
||||
for _ in 0..10 {
|
||||
assert_eq!(
|
||||
refresh.start(RateLimitRefreshOrigin::Recovery, &mut epoch),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded),
|
||||
RateLimitRefreshOutcome::RefreshRecovery
|
||||
);
|
||||
let next = refresh
|
||||
.start(RateLimitRefreshOrigin::Recovery, &mut epoch)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
refresh.finish(next.0, next.1, epoch, RateLimitReadStatus::Succeeded),
|
||||
RateLimitRefreshOutcome::Apply
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use super::backend_banner_fallback_tests::fallback_response;
|
||||
use super::*;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_applies_before_initial_and_queued_startup_prompts() -> Result<()> {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
let mut config = app.config.clone();
|
||||
config.model = Some("gpt-5.4".into());
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui,
|
||||
config.clone(),
|
||||
Some(UserMessage::from("initial prompt")),
|
||||
);
|
||||
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
set_fast_mode_test_catalog(&mut app.chat_widget);
|
||||
app.model_catalog = app.chat_widget.model_catalog();
|
||||
app.pending_startup_thread_start = true;
|
||||
app.chat_widget.set_model("gpt-5.4");
|
||||
app.chat_widget
|
||||
.set_queue_submissions_until_session_configured(/*queue*/ true);
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".into());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server(
|
||||
&config, /*blocked_thread_list*/ None, /*failed_thread_name*/ None,
|
||||
)
|
||||
.await?;
|
||||
let generation = app.rate_limit_hard_stop_generation;
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 1,
|
||||
origin: RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id: 0,
|
||||
},
|
||||
hard_stop_generation: generation,
|
||||
result: Ok(fallback_response()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(requests.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
!render_bottom_popup(&app.chat_widget, /*width*/ 72)
|
||||
.contains("Selected model usage exhausted")
|
||||
);
|
||||
let started = server.start_thread(&config).await?;
|
||||
let thread_id = started.session.thread_id;
|
||||
requests.lock().unwrap().clear();
|
||||
while events.try_recv().is_ok() {}
|
||||
app.handle_startup_thread_started(&mut server, Ok(started))
|
||||
.await?;
|
||||
assert_eq!(app.active_thread_id, Some(thread_id));
|
||||
let submissions = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| {
|
||||
if let AppEvent::CodexOp(AppCommand::UserTurn { model, items, .. }) = event {
|
||||
Some((model, items))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
submissions,
|
||||
vec![(
|
||||
"gpt-5.2".into(),
|
||||
vec![UserInput::Text {
|
||||
text: "initial prompt".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["queued follow-up"]
|
||||
);
|
||||
assert_eq!(
|
||||
requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|request| request.method.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["thread/settings/update"]
|
||||
);
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -40,6 +40,8 @@ fn rate_limit_snapshot(
|
||||
|
||||
fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse {
|
||||
GetAccountRateLimitsResponse {
|
||||
account_id: None,
|
||||
rate_limit_upsell: None,
|
||||
rate_limits: snapshot,
|
||||
rate_limits_by_limit_id: None,
|
||||
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
|
||||
@@ -101,6 +103,50 @@ fn deliver_usage_limit_error(app: &mut App) {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_state_survives_widget_replacement() -> Result<()> {
|
||||
for dismiss in [false, true] {
|
||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
let mut response = account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 100, /*rate_limit_reached_type*/ None,
|
||||
/*spend_control_reached*/ None,
|
||||
));
|
||||
response.rate_limit_upsell = Some(serde_json::json!({
|
||||
"banner_type": "plus_rate_limit_reached", "title": "Usage limit reached",
|
||||
"presentation": "dismissible",
|
||||
"description": "Choose how to continue.",
|
||||
"ctas": [{"action": "view_usage", "label": "View usage"}]
|
||||
}));
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage"));
|
||||
if dismiss {
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
}
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 90);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui,
|
||||
app.config.clone(),
|
||||
/*initial_user_message*/ None,
|
||||
);
|
||||
app.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
set_active_cell(
|
||||
&mut app.chat_widget,
|
||||
Box::new(PlainHistoryCell::new(Vec::new())),
|
||||
);
|
||||
app.chat_widget.pre_draw_tick();
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before);
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before);
|
||||
response.rate_limit_upsell = None;
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -234,6 +280,7 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 0,
|
||||
origin,
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
@@ -313,6 +360,7 @@ async fn stale_rate_limit_read_does_not_dismiss_visible_workspace_advisory() ->
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 0,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
@@ -359,6 +407,7 @@ async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Re
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 0,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
@@ -386,3 +435,113 @@ async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Re
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_rate_limit_read_preserves_visible_backend_banner() -> Result<()> {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
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 mut response = account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 25,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
Some(false),
|
||||
));
|
||||
response.account_id = Some("workspace-a".into());
|
||||
response.rate_limit_upsell = Some(serde_json::json!({
|
||||
"banner_type": "workspace_recovery", "presentation": "inline",
|
||||
"title": "Workspace needs credits", "description": "Ask your owner for credits.",
|
||||
"ctas": []
|
||||
}));
|
||||
app.chat_widget.update_backend_banner(&response);
|
||||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 90);
|
||||
assert!(before.contains("Workspace needs credits"));
|
||||
let request_id = 7;
|
||||
app.chat_widget
|
||||
.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
||||
let generation = app.rate_limit_hard_stop_generation;
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: 0,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
hard_stop_generation: generation,
|
||||
result: Err("transient test failure".into()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_reads_ignore_older_completions() -> Result<()> {
|
||||
let (mut app, _events, _ops) = make_test_app_with_channels().await;
|
||||
let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.chat_widget.set_model("test-model-a");
|
||||
let mut current = account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 100, /*rate_limit_reached_type*/ None,
|
||||
/*spend_control_reached*/ None,
|
||||
));
|
||||
current.rate_limit_upsell = Some(serde_json::json!({
|
||||
"banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline",
|
||||
"title":"Selected model usage exhausted", "description":"Contact your owner.", "ctas":[]
|
||||
}));
|
||||
for request_id in 1..=5 {
|
||||
app.rate_limit_refresh_state
|
||||
.start(
|
||||
RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
&mut app.rate_limit_hard_stop_generation,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for (id, generation, result, expect_visible) in [
|
||||
(2, 0, Ok(current.clone()), true),
|
||||
(
|
||||
1,
|
||||
0,
|
||||
Ok({
|
||||
let mut absent = current.clone();
|
||||
absent.rate_limit_upsell = None;
|
||||
absent
|
||||
}),
|
||||
true,
|
||||
),
|
||||
(4, 0, Err("transient failure".into()), true),
|
||||
(
|
||||
3,
|
||||
0,
|
||||
Ok({
|
||||
let mut absent = current.clone();
|
||||
absent.rate_limit_upsell = None;
|
||||
absent
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(5, 0, Ok(current.clone()), true),
|
||||
] {
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
request_id: id,
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id: id },
|
||||
hard_stop_generation: generation,
|
||||
result,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 90)
|
||||
.contains("Selected model usage exhausted"),
|
||||
expect_visible
|
||||
);
|
||||
}
|
||||
session.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/app/tests/backend_banner_fallback_tests.rs
|
||||
assertion_line: 76
|
||||
expression: rendered
|
||||
---
|
||||
Selected model usage exhausted
|
||||
Add credits to use this model again.
|
||||
|
||||
› 1. View usage
|
||||
|
||||
Press a number to choose · esc to dismiss · type to continue
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.2 medium · /tmp/project
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/app/tests/backend_banner_fallback_tests.rs
|
||||
assertion_line: 124
|
||||
expression: "notices[0]"
|
||||
---
|
||||
• Automatically switched to gpt-5.2 medium due to usage limits.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/app/tests/backend_banner_fallback_tests.rs
|
||||
assertion_line: 234
|
||||
expression: rendered
|
||||
---
|
||||
› Ask Codex to do anything
|
||||
|
||||
gpt-5.4 default · /tmp/project
|
||||
@@ -173,6 +173,8 @@ pub(crate) enum RateLimitRefreshOrigin {
|
||||
ResetPicker { request_id: u64 },
|
||||
/// Refresh requested after a reset credit was successfully consumed.
|
||||
ResetConsume { request_id: u64 },
|
||||
/// Refresh backend recovery after an inference limit error.
|
||||
Recovery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -524,6 +526,7 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Result of refreshing rate limits.
|
||||
RateLimitsLoaded {
|
||||
request_id: u64,
|
||||
origin: RateLimitRefreshOrigin,
|
||||
hard_stop_generation: u64,
|
||||
result: Result<GetAccountRateLimitsResponse, String>,
|
||||
@@ -601,6 +604,7 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Result of notifying the workspace owner.
|
||||
AddCreditsNudgeEmailFinished {
|
||||
request_id: Uuid,
|
||||
result: Result<AddCreditsNudgeEmailStatus, String>,
|
||||
},
|
||||
|
||||
|
||||
@@ -2461,6 +2461,8 @@ mod tests {
|
||||
#[test]
|
||||
fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
|
||||
let response = GetAccountRateLimitsResponse {
|
||||
account_id: None,
|
||||
rate_limit_upsell: None,
|
||||
rate_limits: rate_limit_snapshot("codex"),
|
||||
rate_limits_by_limit_id: Some(HashMap::from([
|
||||
("codex".to_string(), rate_limit_snapshot("codex")),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Backend banner payload and CTA mapping, supplied by the existing account usage read.
|
||||
//!
|
||||
//! The backend owns eligibility, copy, actions, and ordered model fallback instructions.
|
||||
|
||||
use codex_protocol::account::PlanType;
|
||||
use serde::Deserialize;
|
||||
|
||||
mod actions;
|
||||
mod render;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "backend_banners_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct BackendBanner {
|
||||
pub(crate) banner_type: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) ctas: Vec<BackendBannerCta>,
|
||||
pub(crate) reset_at: Option<i64>,
|
||||
pub(crate) model_slug: Option<String>,
|
||||
pub(crate) blocked_model_slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) fallback_model_slugs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) presentation: BannerPresentation,
|
||||
request_url: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub(crate) account_id: String,
|
||||
#[serde(skip)]
|
||||
pub(crate) plan_type: Option<PlanType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum BannerPresentation {
|
||||
#[default]
|
||||
Inline,
|
||||
Dismissible,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct BackendBannerCta {
|
||||
action: String,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl BackendBanner {
|
||||
/// Parse supported, bounded content before constructing rendered copy or CTA closures.
|
||||
pub(crate) fn parse(raw: &serde_json::Value) -> Option<Self> {
|
||||
serde_json::from_value::<Self>(raw.clone())
|
||||
.ok()
|
||||
.filter(|banner| {
|
||||
let valid_slug = |slug: &str| {
|
||||
!slug.trim().is_empty()
|
||||
&& slug.len() <= 256
|
||||
&& !slug.chars().any(char::is_control)
|
||||
};
|
||||
banner.title.len() <= 1024
|
||||
&& banner.description.len() <= 4096
|
||||
&& banner.ctas.len() <= 8
|
||||
&& !banner.title.trim().is_empty()
|
||||
&& banner.title.lines().count() <= 3
|
||||
&& banner.description.lines().count() <= 12
|
||||
&& banner.blocked_model_slug.as_deref().is_none_or(valid_slug)
|
||||
&& banner.fallback_model_slugs.len() <= 16
|
||||
&& banner
|
||||
.fallback_model_slugs
|
||||
.iter()
|
||||
.all(|slug| valid_slug(slug))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Map backend CTA names to existing CLI actions and desktop-equivalent browser destinations.
|
||||
|
||||
use super::BackendBanner;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_protocol::account::PlanType;
|
||||
|
||||
const USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage";
|
||||
const WORKSPACE_USAGE_URL: &str = "https://chatgpt.com/admin/usage-limits/workspace";
|
||||
|
||||
pub(super) enum BannerAction {
|
||||
OpenUrl(String),
|
||||
NotifyOwner(AddCreditsNudgeCreditType),
|
||||
ResetUsage,
|
||||
}
|
||||
|
||||
impl BackendBanner {
|
||||
pub(super) fn resolve_action(&self, action: &str) -> Option<BannerAction> {
|
||||
match action {
|
||||
"notify_owner" | "contact_owner" => {
|
||||
return Some(BannerAction::NotifyOwner(
|
||||
AddCreditsNudgeCreditType::Credits,
|
||||
));
|
||||
}
|
||||
"request_increase" => {
|
||||
let Some(destination) = self.request_url.as_deref() else {
|
||||
return Some(BannerAction::NotifyOwner(
|
||||
AddCreditsNudgeCreditType::UsageLimit,
|
||||
));
|
||||
};
|
||||
let url = url::Url::parse(destination).ok()?;
|
||||
if !matches!(url.scheme(), "https" | "http")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
return Some(BannerAction::OpenUrl(url.to_string()));
|
||||
}
|
||||
// Keep the existing picker and explicit confirmation before consuming a reset.
|
||||
"reset_usage" => return Some(BannerAction::ResetUsage),
|
||||
_ => {}
|
||||
}
|
||||
let destination = match action {
|
||||
"add_credits" | "buy_credits"
|
||||
if self.plan_type.is_some_and(PlanType::is_workspace_account) =>
|
||||
{
|
||||
"https://chatgpt.com/admin/billing?codex_credit_action=add_credits".to_string()
|
||||
}
|
||||
"add_credits" | "buy_credits" => format!("{USAGE_URL}?credits_modal=true"),
|
||||
"buy_reset" => "https://chatgpt.com/codex/purchase/reset".to_string(),
|
||||
"view_usage" | "request_increase_usage_settings" => USAGE_URL.to_string(),
|
||||
"view_workspace_usage" | "increase_spend_cap" => WORKSPACE_USAGE_URL.to_string(),
|
||||
"open_plus_pricing_web" => "https://chatgpt.com/explore/plus".to_string(),
|
||||
"open_pro_pricing_web" => "https://chatgpt.com/explore/pro".to_string(),
|
||||
"open_pricing_dialog" => {
|
||||
let mut url = url::Url::parse("https://chatgpt.com/").ok()?;
|
||||
let target = if matches!(self.plan_type, Some(PlanType::Plus | PlanType::ProLite)) {
|
||||
"pro"
|
||||
} else {
|
||||
"plus"
|
||||
};
|
||||
url.query_pairs_mut()
|
||||
.append_pair("cta_tab", "personal")
|
||||
.append_pair("highlight_plan", target);
|
||||
if self.plan_type == Some(PlanType::ProLite) {
|
||||
url.query_pairs_mut().append_pair("pro_variant", "2x");
|
||||
}
|
||||
url.set_fragment(Some("pricing"));
|
||||
url.to_string()
|
||||
}
|
||||
// Desktop-only referral and Premium dialogs need their own CLI flow.
|
||||
_ => return None,
|
||||
};
|
||||
let mut destination = url::Url::parse(&destination).ok()?;
|
||||
if destination.path().starts_with("/admin/") {
|
||||
if self.account_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The existing admin route selects the requested workspace before opening the modal.
|
||||
destination
|
||||
.query_pairs_mut()
|
||||
.append_pair("account_id", &self.account_id);
|
||||
}
|
||||
Some(BannerAction::OpenUrl(destination.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Render backend-owned copy and ordered CTA labels with the shared inline banner UI.
|
||||
|
||||
use super::BackendBanner;
|
||||
use super::BannerPresentation;
|
||||
use super::actions::BannerAction;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::ActionableBanner;
|
||||
use crate::bottom_pane::BannerDismissal;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
|
||||
impl BackendBanner {
|
||||
pub(crate) fn actionable_banner(&self) -> ActionableBanner {
|
||||
let reset_time = self
|
||||
.reset_at
|
||||
.and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0))
|
||||
.map(|time| {
|
||||
crate::status::format_reset_timestamp(time.with_timezone(&Local), Local::now())
|
||||
});
|
||||
let copy = |text: &str| {
|
||||
let text: String = text
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\n')
|
||||
.collect();
|
||||
match reset_time.as_deref() {
|
||||
Some(reset_time) => text.replace("{time}", reset_time),
|
||||
None => text,
|
||||
}
|
||||
};
|
||||
let actions = self
|
||||
.ctas
|
||||
.iter()
|
||||
.filter_map(|cta| {
|
||||
if cta.label.trim().is_empty()
|
||||
|| cta.label.len() > 256
|
||||
|| cta.label.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let action = self.resolve_action(&cta.action)?;
|
||||
Some(SelectionItem {
|
||||
name: cta.label.clone(),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(match &action {
|
||||
BannerAction::OpenUrl(url) => {
|
||||
AppEvent::OpenUrlInBrowser { url: url.clone() }
|
||||
}
|
||||
BannerAction::NotifyOwner(credit_type) => {
|
||||
AppEvent::SendAddCreditsNudgeEmail {
|
||||
credit_type: *credit_type,
|
||||
}
|
||||
}
|
||||
BannerAction::ResetUsage => AppEvent::OpenRateLimitResetCredits,
|
||||
})
|
||||
})],
|
||||
dismiss_on_select: false,
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ActionableBanner {
|
||||
title: copy(&self.title),
|
||||
description: copy(&self.description),
|
||||
actions,
|
||||
dismissal: if self.presentation == BannerPresentation::Dismissible {
|
||||
BannerDismissal::Dismissible
|
||||
} else {
|
||||
BannerDismissal::Persistent
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use super::BackendBanner;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_more_actions_than_the_inline_banner_can_display() {
|
||||
let labels: Vec<_> = (1..=8).map(|index| format!("Action {index}")).collect();
|
||||
let mut raw = json!({
|
||||
"banner_type": "usage_limit", "title": "Usage limit reached",
|
||||
"description": "Choose how to continue.",
|
||||
"ctas": labels.iter().map(|label| json!({
|
||||
"action": "view_usage", "label": label,
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
let banner = BackendBanner::parse(&raw).expect("eight actions fit");
|
||||
assert_eq!(
|
||||
banner
|
||||
.actionable_banner()
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|action| action.name)
|
||||
.collect::<Vec<_>>(),
|
||||
labels
|
||||
);
|
||||
|
||||
raw["ctas"].as_array_mut().unwrap().push(json!({
|
||||
"action": "view_usage", "label": "Hidden ninth action",
|
||||
}));
|
||||
assert_eq!(BackendBanner::parse(&raw), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unsupported_or_unrenderable_content() {
|
||||
let valid = json!({
|
||||
"banner_type": "usage_limit", "title": "Usage limit reached",
|
||||
"description": "Choose how to continue.", "ctas": [],
|
||||
});
|
||||
for invalid_fields in [
|
||||
json!({"presentation":"future_mode"}),
|
||||
json!({"presentation":null}),
|
||||
json!({"title":" "}),
|
||||
json!({"title":"x".repeat(1025)}),
|
||||
json!({"title":"line\n".repeat(4)}),
|
||||
json!({"description":"x".repeat(4097)}),
|
||||
json!({"description":"line\n".repeat(13)}),
|
||||
json!({"blocked_model_slug":""}),
|
||||
json!({"blocked_model_slug":"bad\nslug"}),
|
||||
json!({"fallback_model_slugs":["x".repeat(257)]}),
|
||||
json!({"fallback_model_slugs":vec!["model"; 17]}),
|
||||
] {
|
||||
let mut raw = valid.clone();
|
||||
raw.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(invalid_fields.as_object().unwrap().clone());
|
||||
assert_eq!(BackendBanner::parse(&raw), None, "{invalid_fields}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Shared title, description, and CTA content for banners in the TUI bottom pane.
|
||||
//!
|
||||
//! Workspace notices and backend banners share the existing selection list for CTA dispatch.
|
||||
//! The inline wrapper owns presentation and dismissal while preserving normal composer input.
|
||||
|
||||
use super::BottomPane;
|
||||
use super::BottomPaneView;
|
||||
use super::SelectionItem;
|
||||
use super::SelectionViewParams;
|
||||
use super::list_selection_view::ListSelectionView;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ActionableBanner {
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) actions: Vec<SelectionItem>,
|
||||
pub(crate) initial_selected_idx: Option<usize>,
|
||||
pub(crate) dismissal: BannerDismissal,
|
||||
pub(crate) view_id: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) enum BannerDismissal {
|
||||
#[default]
|
||||
Dismissible,
|
||||
Persistent,
|
||||
}
|
||||
|
||||
pub(super) struct InlineBanner {
|
||||
content: InlineBannerContent,
|
||||
dismissal: BannerDismissal,
|
||||
shown: Cell<bool>,
|
||||
// Current visibility is separate from whether this banner has ever been shown.
|
||||
pub(super) visible: Cell<bool>,
|
||||
dismissed: bool,
|
||||
visible_action_count: Cell<usize>,
|
||||
}
|
||||
|
||||
enum InlineBannerContent {
|
||||
Actions(Box<ListSelectionView>),
|
||||
Information {
|
||||
header: Box<dyn Renderable>,
|
||||
hint: Line<'static>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Renderable for InlineBanner {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.visible_action_count.set(0);
|
||||
self.visible.set(!self.dismissed && !area.is_empty());
|
||||
if !self.visible.get() {
|
||||
return;
|
||||
}
|
||||
self.shown.set(true);
|
||||
match &self.content {
|
||||
InlineBannerContent::Actions(view) => {
|
||||
view.render(area, buf);
|
||||
self.visible_action_count.set(view.rendered_item_count());
|
||||
}
|
||||
InlineBannerContent::Information { header, hint } => {
|
||||
let content = area.inset(Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2,
|
||||
));
|
||||
header.render(content, buf);
|
||||
let y = content
|
||||
.y
|
||||
.saturating_add(header.desired_height(content.width) + 1);
|
||||
if y < area.bottom() {
|
||||
Renderable::render(
|
||||
hint,
|
||||
Rect::new(content.x, y, content.width, /*height*/ 1),
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.dismissed {
|
||||
return 0;
|
||||
}
|
||||
match &self.content {
|
||||
InlineBannerContent::Actions(view) => view.desired_height(width),
|
||||
InlineBannerContent::Information { header, hint } => {
|
||||
header.desired_height(width.saturating_sub(4))
|
||||
+ if hint.width() == 0 { 2 } else { 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiline banner copy uses the same word wrapping for measurement and rendering.
|
||||
struct BannerContent(Vec<Line<'static>>);
|
||||
|
||||
impl BannerContent {
|
||||
fn wrapped_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines = word_wrap_lines(&self.0, width as usize);
|
||||
if lines.len() > 8 {
|
||||
lines.truncate(7);
|
||||
lines.push("…".dim().into());
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for BannerContent {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
Widget::render(Paragraph::new(lines), area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.wrapped_lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ActionableBanner> for SelectionViewParams {
|
||||
fn from(banner: ActionableBanner) -> Self {
|
||||
let lines = banner
|
||||
.title
|
||||
.lines()
|
||||
.map(|line| Line::from(line.to_owned().bold()))
|
||||
.chain(
|
||||
banner
|
||||
.description
|
||||
.lines()
|
||||
.map(|line| Line::from(line.to_owned().dim())),
|
||||
)
|
||||
.collect();
|
||||
Self {
|
||||
view_id: banner.view_id,
|
||||
header: Box::new(BannerContent(lines)),
|
||||
items: banner.actions,
|
||||
initial_selected_idx: banner.initial_selected_idx,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPane {
|
||||
pub(crate) fn show_actionable_banner(&mut self, banner: ActionableBanner) {
|
||||
self.show_selection_view(banner.into());
|
||||
}
|
||||
|
||||
/// Display a banner above the composer without interrupting active dialogs or losing a draft.
|
||||
pub(crate) fn set_inline_banner(&mut self, banner: Option<ActionableBanner>) {
|
||||
self.inline_banner = banner.map(|banner| {
|
||||
let dismissal = banner.dismissal;
|
||||
let has_actions = !banner.actions.is_empty();
|
||||
let mut params: SelectionViewParams = banner.into();
|
||||
params.allow_cancel = dismissal == BannerDismissal::Dismissible;
|
||||
for item in &mut params.items {
|
||||
item.dismiss_on_select = false;
|
||||
}
|
||||
let hint = match (dismissal, has_actions) {
|
||||
(BannerDismissal::Persistent, true) => "Press a number to choose",
|
||||
(BannerDismissal::Persistent, false) => "",
|
||||
(BannerDismissal::Dismissible, true) => {
|
||||
"Press a number to choose · esc to dismiss · type to continue"
|
||||
}
|
||||
(BannerDismissal::Dismissible, false) => "esc to dismiss · type to continue",
|
||||
};
|
||||
let hint: Line<'static> = hint.dim().into();
|
||||
params.footer_hint = Some(hint.clone());
|
||||
InlineBanner {
|
||||
content: if has_actions {
|
||||
InlineBannerContent::Actions(Box::new(ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
)))
|
||||
} else {
|
||||
InlineBannerContent::Information {
|
||||
header: params.header,
|
||||
hint,
|
||||
}
|
||||
},
|
||||
dismissal,
|
||||
shown: Cell::new(false),
|
||||
visible: Cell::new(false),
|
||||
dismissed: false,
|
||||
visible_action_count: Cell::new(0),
|
||||
}
|
||||
});
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Observation for the owning controller; model hiding is not a dismissal.
|
||||
pub(crate) fn inline_banner_lifecycle(&self) -> (bool, bool) {
|
||||
self.inline_banner
|
||||
.as_ref()
|
||||
.map_or((false, false), |banner| {
|
||||
(banner.shown.get(), banner.dismissed)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn inline_banner_accepts_dismissal(&self) -> bool {
|
||||
self.inline_banner.as_ref().is_some_and(|banner| {
|
||||
banner.visible.get()
|
||||
&& !banner.dismissed
|
||||
&& banner.dismissal == BannerDismissal::Dismissible
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_inline_banner_key(&mut self, key: KeyEvent) -> bool {
|
||||
// Draft input, completion menus, and paste bursts retain all of their normal keys.
|
||||
if !self.composer_is_empty()
|
||||
|| self.composer.popup_active()
|
||||
|| self.composer.is_in_paste_burst()
|
||||
|| self.composer_should_handle_vim_insert_escape(key)
|
||||
|| self.is_task_running
|
||||
|| key.kind != KeyEventKind::Press
|
||||
|| key.modifiers != KeyModifiers::NONE
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(banner) = self.inline_banner.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !banner.visible.get() || banner.dismissed {
|
||||
return false;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc if banner.dismissal == BannerDismissal::Persistent => return false,
|
||||
KeyCode::Esc => {
|
||||
banner.dismissed = true;
|
||||
}
|
||||
KeyCode::Char(digit @ '1'..='9') => {
|
||||
if digit as usize - '1' as usize >= banner.visible_action_count.get() {
|
||||
return false;
|
||||
}
|
||||
let InlineBannerContent::Actions(view) = &mut banner.content else {
|
||||
return false;
|
||||
};
|
||||
view.handle_key_event(key);
|
||||
let _ = view.take_last_selected_index();
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
self.request_redraw();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,7 @@ pub(crate) struct ListSelectionView {
|
||||
name_column_width: Option<usize>,
|
||||
filtered_indices: Vec<usize>,
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
rendered_item_count: std::cell::Cell<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
initial_selected_idx: Option<usize>,
|
||||
side_content: Box<dyn Renderable>,
|
||||
@@ -408,6 +409,7 @@ impl ListSelectionView {
|
||||
name_column_width: params.name_column_width,
|
||||
filtered_indices: Vec::new(),
|
||||
last_selected_actual_idx: None,
|
||||
rendered_item_count: std::cell::Cell::new(0),
|
||||
header,
|
||||
initial_selected_idx: params.initial_selected_idx,
|
||||
side_content: params.side_content,
|
||||
@@ -1197,6 +1199,7 @@ impl Renderable for ListSelectionView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.rendered_item_count.set(0);
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -1316,7 +1319,7 @@ impl Renderable for ListSelectionView {
|
||||
width: effective_rows_width.max(1),
|
||||
height: list_area.height,
|
||||
};
|
||||
match self.row_display {
|
||||
let rendered_rows = match self.row_display {
|
||||
SelectionRowDisplay::Wrapped => render_rows_with_col_width_mode(
|
||||
render_area,
|
||||
buf,
|
||||
@@ -1336,6 +1339,7 @@ impl Renderable for ListSelectionView {
|
||||
column_width,
|
||||
),
|
||||
};
|
||||
self.rendered_item_count.set(rendered_rows.items);
|
||||
}
|
||||
|
||||
// -- Side content (preview panel) --
|
||||
@@ -1432,6 +1436,12 @@ impl Renderable for ListSelectionView {
|
||||
}
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
pub(crate) fn rendered_item_count(&self) -> usize {
|
||||
self.rendered_item_count.get()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//!
|
||||
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
//! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer;
|
||||
//! drafts, paste bursts, and active dialogs keep their normal input routing.
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -55,6 +57,7 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
mod action_required_title;
|
||||
mod actionable_banner;
|
||||
mod app_link_view;
|
||||
mod apply_patch_header;
|
||||
mod approval_overlay;
|
||||
@@ -67,6 +70,8 @@ mod status_surface_preview;
|
||||
mod title_setup;
|
||||
pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX;
|
||||
pub(crate) use action_required_title::build_action_required_title_text;
|
||||
pub(crate) use actionable_banner::ActionableBanner;
|
||||
pub(crate) use actionable_banner::BannerDismissal;
|
||||
pub(crate) use app_link_view::AppLinkElicitationTarget;
|
||||
pub(crate) use app_link_view::AppLinkSuggestionType;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
@@ -243,6 +248,7 @@ pub(crate) struct BottomPane {
|
||||
|
||||
/// Inline status indicator shown above the composer while a task is running.
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
inline_banner: Option<actionable_banner::InlineBanner>,
|
||||
/// Unified exec session summary source.
|
||||
///
|
||||
/// When a status row exists, this summary is mirrored inline in that row;
|
||||
@@ -313,6 +319,7 @@ impl BottomPane {
|
||||
disable_paste_burst,
|
||||
is_task_running: false,
|
||||
status: None,
|
||||
inline_banner: None,
|
||||
unified_exec_footer: UnifiedExecFooter::new(),
|
||||
pending_input_preview: PendingInputPreview::new(),
|
||||
pending_thread_approvals: PendingThreadApprovals::new(),
|
||||
@@ -691,6 +698,9 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
} else {
|
||||
if self.handle_inline_banner_key(key_event) {
|
||||
return InputResult::None;
|
||||
}
|
||||
// If a task is running and a status line is visible, allow the
|
||||
// configured action to interrupt even while the composer has focus.
|
||||
// When a popup is active, prefer dismissing it over interrupting the task.
|
||||
@@ -1461,7 +1471,10 @@ impl BottomPane {
|
||||
/// overlays or popups and not running a task. This is the safe context to
|
||||
/// use Esc-Esc for backtracking from the main view.
|
||||
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
|
||||
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
!self.is_task_running
|
||||
&& self.view_stack.is_empty()
|
||||
&& !self.composer.popup_active()
|
||||
&& !self.inline_banner_accepts_dismissal()
|
||||
}
|
||||
|
||||
/// Return true when no popups or modal views are active, regardless of task state.
|
||||
@@ -1815,10 +1828,22 @@ impl BottomPane {
|
||||
&'_ self,
|
||||
composer_right_reserve: u16,
|
||||
) -> RenderableItem<'_> {
|
||||
if (self.is_task_running || !self.view_stack.is_empty())
|
||||
&& let Some(banner) = &self.inline_banner
|
||||
{
|
||||
banner.visible.set(false);
|
||||
}
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
} else {
|
||||
let mut flex = FlexRenderable::new();
|
||||
if let Some(banner) = self
|
||||
.inline_banner
|
||||
.as_ref()
|
||||
.filter(|_| !self.is_task_running)
|
||||
{
|
||||
flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner));
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(/*flex*/ 0, RenderableItem::Borrowed(status));
|
||||
}
|
||||
@@ -1952,6 +1977,9 @@ impl Renderable for BottomPane {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[path = "actionable_banner_tests.rs"]
|
||||
mod actionable_banner_tests;
|
||||
|
||||
use super::*;
|
||||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
use crate::app_command::AppCommand as Op;
|
||||
@@ -2030,6 +2058,93 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_banner_snapshot() {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
let mut pane = test_pane(AppEventSender::new(tx));
|
||||
pane.set_inline_banner(Some(ActionableBanner {
|
||||
title: "Choose how to continue working".to_string(),
|
||||
description: "A long description wraps to fit the available terminal width.\n"
|
||||
.repeat(/*n*/ 8),
|
||||
actions: vec![
|
||||
SelectionItem {
|
||||
name: "Open usage settings".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Notify owner".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}));
|
||||
let width = 44;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.desired_height(width),
|
||||
);
|
||||
assert_snapshot!(
|
||||
"inline_banner_wrapped_and_truncated",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
assert!(!pane.is_normal_backtrack_mode());
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(pane.is_normal_backtrack_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_banner_snapshots_and_numbered_actions() {
|
||||
for (kind, action, label) in [
|
||||
("personal_limit", "view_usage", "View usage"),
|
||||
("workspace_member_credits", "notify_owner", "Notify owner"),
|
||||
] {
|
||||
let banner = crate::backend_banners::BackendBanner::parse(&serde_json::json!({
|
||||
"banner_type": kind,
|
||||
"presentation": "dismissible",
|
||||
"title": "Usage limit\u{7} reached",
|
||||
"description": "Your included usage is depleted.\nChoose an action to continue.",
|
||||
"ctas": [
|
||||
{"action": "unsupported", "label": "Hidden action"},
|
||||
{"action": "view_usage", "label": "Open usage settings"},
|
||||
{"action": action, "label": label},
|
||||
{"action": "view_usage", "label": "Extra action"}
|
||||
]
|
||||
}))
|
||||
.expect("valid optional banner");
|
||||
let (tx, mut rx) = unbounded_channel();
|
||||
let mut pane = test_pane(AppEventSender::new(tx));
|
||||
pane.set_inline_banner(Some(banner.actionable_banner()));
|
||||
let width = 44;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.desired_height(width),
|
||||
);
|
||||
assert_snapshot!(
|
||||
format!("backend_banner_{kind}"),
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
let selected = match rx.try_recv().expect("numbered CTA dispatch") {
|
||||
AppEvent::OpenUrlInBrowser { url } => url,
|
||||
AppEvent::SendAddCreditsNudgeEmail { credit_type } => format!("{credit_type:?}"),
|
||||
other => panic!("unexpected banner action: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
selected,
|
||||
if action == "view_usage" {
|
||||
"https://chatgpt.com/codex/settings/usage"
|
||||
} else {
|
||||
"Credits"
|
||||
}
|
||||
);
|
||||
assert!(pane.inline_banner.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DismissibleView {
|
||||
id: Option<&'static str>,
|
||||
|
||||
@@ -452,6 +452,12 @@ fn adjust_start_for_wrapped_selection_visibility(
|
||||
/// and behavior for selection popups.
|
||||
/// Returns the number of terminal lines actually rendered (including the
|
||||
/// single-line empty placeholder when shown).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct RenderedRows {
|
||||
pub(crate) lines: u16,
|
||||
pub(crate) items: usize,
|
||||
}
|
||||
|
||||
fn render_rows_inner(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
@@ -460,18 +466,21 @@ fn render_rows_inner(
|
||||
max_results: usize,
|
||||
empty_message: &str,
|
||||
column_width: ColumnWidthConfig,
|
||||
) -> u16 {
|
||||
) -> RenderedRows {
|
||||
if rows_all.is_empty() {
|
||||
if area.height > 0 {
|
||||
Line::from(empty_message.dim().italic()).render(area, buf);
|
||||
}
|
||||
// Count the placeholder line only when there is vertical space to draw it.
|
||||
return u16::from(area.height > 0);
|
||||
return RenderedRows {
|
||||
lines: u16::from(area.height > 0),
|
||||
items: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let max_items = max_results.min(rows_all.len());
|
||||
if max_items == 0 {
|
||||
return 0;
|
||||
return RenderedRows::default();
|
||||
}
|
||||
let desc_measure_items = max_items.min(area.height.max(1) as usize);
|
||||
|
||||
@@ -499,6 +508,7 @@ fn render_rows_inner(
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
let mut cur_y = area.y;
|
||||
let mut rendered_lines: u16 = 0;
|
||||
let mut rendered_items = 0;
|
||||
for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
@@ -513,6 +523,7 @@ fn render_rows_inner(
|
||||
);
|
||||
|
||||
// Render the wrapped lines.
|
||||
let mut rendered_item = false;
|
||||
for line in wrapped {
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
@@ -528,10 +539,17 @@ fn render_rows_inner(
|
||||
);
|
||||
cur_y = cur_y.saturating_add(1);
|
||||
rendered_lines = rendered_lines.saturating_add(1);
|
||||
rendered_item = true;
|
||||
}
|
||||
if rendered_item {
|
||||
rendered_items += 1;
|
||||
}
|
||||
}
|
||||
|
||||
rendered_lines
|
||||
RenderedRows {
|
||||
lines: rendered_lines,
|
||||
items: rendered_items,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState, with shared styling
|
||||
@@ -559,6 +577,7 @@ pub(crate) fn render_rows(
|
||||
empty_message,
|
||||
ColumnWidthConfig::default(),
|
||||
)
|
||||
.lines
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState and explicit
|
||||
@@ -566,7 +585,7 @@ pub(crate) fn render_rows(
|
||||
///
|
||||
/// This is the low-level entry point for callers that need to thread a mode
|
||||
/// through higher-level configuration.
|
||||
/// Returns the number of terminal lines actually rendered.
|
||||
/// Returns the terminal lines and selectable items actually rendered.
|
||||
pub(crate) fn render_rows_with_col_width_mode(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
@@ -575,7 +594,7 @@ pub(crate) fn render_rows_with_col_width_mode(
|
||||
max_results: usize,
|
||||
empty_message: &str,
|
||||
column_width: ColumnWidthConfig,
|
||||
) -> u16 {
|
||||
) -> RenderedRows {
|
||||
render_rows_inner(
|
||||
area,
|
||||
buf,
|
||||
@@ -609,10 +628,12 @@ pub(crate) fn render_rows_single_line(
|
||||
empty_message,
|
||||
ColumnWidthConfig::default(),
|
||||
)
|
||||
.lines
|
||||
}
|
||||
|
||||
/// Render a list of rows as a single line each (no wrapping), truncating overflow with an
|
||||
/// ellipsis while honoring the configured column width behavior.
|
||||
/// Returns the terminal lines and selectable items actually rendered.
|
||||
pub(crate) fn render_rows_single_line_with_col_width_mode(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
@@ -621,13 +642,16 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
|
||||
max_results: usize,
|
||||
empty_message: &str,
|
||||
column_width: ColumnWidthConfig,
|
||||
) -> u16 {
|
||||
) -> RenderedRows {
|
||||
if rows_all.is_empty() {
|
||||
if area.height > 0 {
|
||||
Line::from(empty_message.dim().italic()).render(area, buf);
|
||||
}
|
||||
// Count the placeholder line only when there is vertical space to draw it.
|
||||
return u16::from(area.height > 0);
|
||||
return RenderedRows {
|
||||
lines: u16::from(area.height > 0),
|
||||
items: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let visible_items = max_results
|
||||
@@ -686,7 +710,10 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
|
||||
rendered_lines = rendered_lines.saturating_add(1);
|
||||
}
|
||||
|
||||
rendered_lines
|
||||
RenderedRows {
|
||||
lines: rendered_lines,
|
||||
items: rendered_lines as usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of terminal rows required to render up to `max_results`
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
Usage limit reached
|
||||
Your included usage is depleted.
|
||||
Choose an action to continue.
|
||||
|
||||
› 1. Open usage settings
|
||||
2. View usage
|
||||
3. Extra action
|
||||
|
||||
Press a number to choose · esc to dismiss
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
Usage limit reached
|
||||
Your included usage is depleted.
|
||||
Choose an action to continue.
|
||||
|
||||
› 1. Open usage settings
|
||||
2. Notify owner
|
||||
3. Extra action
|
||||
|
||||
Press a number to choose · esc to dismiss
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
|
||||
Choose how to continue working
|
||||
A long description wraps to fit the
|
||||
available terminal width.
|
||||
A long description wraps to fit the
|
||||
available terminal width.
|
||||
A long description wraps to fit the
|
||||
available terminal width.
|
||||
…
|
||||
|
||||
› 1. Open usage settings
|
||||
2. Notify owner
|
||||
|
||||
Press a number to choose · esc to dismiss
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::super::actionable_banner::BannerDismissal;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn hidden_banner_preserves_input_before_render_and_after_resize() {
|
||||
let (tx, mut rx) = unbounded_channel();
|
||||
let mut pane = test_pane_with_disable_paste_burst(
|
||||
AppEventSender::new(tx),
|
||||
/*disable_paste_burst*/ true,
|
||||
);
|
||||
pane.set_inline_banner(Some(ActionableBanner {
|
||||
title: "Usage limit reached".into(),
|
||||
actions: vec![SelectionItem {
|
||||
name: "View usage".into(),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: "https://example.com/usage".into(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}));
|
||||
let width = 70;
|
||||
let hidden_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.composer.desired_height(width),
|
||||
);
|
||||
let visible_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.desired_height(width),
|
||||
);
|
||||
|
||||
// Keys remain composer input before the first frame and after resizing a
|
||||
// previously visible banner out of view.
|
||||
for render_hidden in [false, true] {
|
||||
if render_hidden {
|
||||
let rendered = render_snapshot(&pane, hidden_area);
|
||||
assert!(!rendered.contains("View usage"));
|
||||
}
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert_eq!(pane.composer_text(), "1");
|
||||
assert!(rx.try_recv().is_err());
|
||||
pane.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||||
assert!(pane.is_normal_backtrack_mode());
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
let rendered = render_snapshot(&pane, visible_area);
|
||||
assert!(rendered.contains("View usage"));
|
||||
assert!(!pane.is_normal_backtrack_mode());
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert_eq!(pane.composer_text(), "");
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url })
|
||||
if url == "https://example.com/usage")
|
||||
);
|
||||
}
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(pane.is_normal_backtrack_mode());
|
||||
assert!(!render_snapshot(&pane, visible_area).contains("View usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_clipped_banner_preserves_hidden_action_shortcuts() {
|
||||
let (tx, mut rx) = unbounded_channel();
|
||||
let mut pane = test_pane_with_disable_paste_burst(
|
||||
AppEventSender::new(tx),
|
||||
/*disable_paste_burst*/ true,
|
||||
);
|
||||
pane.set_inline_banner(Some(ActionableBanner {
|
||||
title: "Usage limit reached".into(),
|
||||
actions: vec![SelectionItem {
|
||||
name: "View usage".into(),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: "https://example.com/usage".into(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}));
|
||||
let width = 70;
|
||||
let footer_only_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.composer.desired_height(width) + 1,
|
||||
);
|
||||
|
||||
let rendered = render_snapshot(&pane, footer_only_area);
|
||||
assert!(!rendered.contains("View usage"));
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert_eq!(pane.composer_text(), "1");
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_hidden_during_task_waits_for_redraw_before_accepting_dismissal() {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
let mut pane = test_pane(AppEventSender::new(tx));
|
||||
pane.set_inline_banner(Some(ActionableBanner {
|
||||
title: "Usage limit reached".into(),
|
||||
..Default::default()
|
||||
}));
|
||||
let width = 70;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.desired_height(width),
|
||||
);
|
||||
assert!(render_snapshot(&pane, area).contains("Usage limit reached"));
|
||||
|
||||
pane.set_task_running(/*running*/ true);
|
||||
assert!(!render_snapshot(&pane, area).contains("Usage limit reached"));
|
||||
pane.set_task_running(/*running*/ false);
|
||||
assert!(pane.is_normal_backtrack_mode());
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(render_snapshot(&pane, area).contains("Usage limit reached"));
|
||||
assert!(!pane.is_normal_backtrack_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn information_banner_preserves_input_and_honors_dismissal() {
|
||||
for dismissal in [BannerDismissal::Persistent, BannerDismissal::Dismissible] {
|
||||
let (tx, _rx) = unbounded_channel();
|
||||
let mut pane = test_pane_with_disable_paste_burst(
|
||||
AppEventSender::new(tx),
|
||||
/*disable_paste_burst*/ true,
|
||||
);
|
||||
pane.set_inline_banner(Some(ActionableBanner {
|
||||
title: "Usage limit reached".into(),
|
||||
description: "You can change models or request more usage.".into(),
|
||||
dismissal,
|
||||
..Default::default()
|
||||
}));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
/*width*/ 70,
|
||||
pane.desired_height(/*width*/ 70),
|
||||
);
|
||||
let rendered = render_snapshot(&pane, area);
|
||||
assert!(!rendered.contains("no matches"));
|
||||
assert!(!rendered.contains("Press a number"));
|
||||
assert_eq!(
|
||||
rendered.contains("esc to dismiss"),
|
||||
dismissal == BannerDismissal::Dismissible
|
||||
);
|
||||
if dismissal == BannerDismissal::Persistent {
|
||||
assert_snapshot!("information_banner_persistent", rendered);
|
||||
} else {
|
||||
assert_snapshot!("information_banner_dismissible", rendered);
|
||||
}
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
render_snapshot(&pane, area).contains("Usage limit reached"),
|
||||
dismissal == BannerDismissal::Persistent
|
||||
);
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert_eq!(pane.composer_text(), "1");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/tests/actionable_banner_tests.rs
|
||||
assertion_line: 34
|
||||
expression: rendered
|
||||
---
|
||||
|
||||
Usage limit reached
|
||||
You can change models or request more usage.
|
||||
|
||||
esc to dismiss · type to continue
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/tests/actionable_banner_tests.rs
|
||||
assertion_line: 32
|
||||
expression: rendered
|
||||
---
|
||||
|
||||
Usage limit reached
|
||||
You can change models or request more usage.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
@@ -393,6 +393,7 @@ mod permission_shortcuts;
|
||||
mod permissions_menu;
|
||||
pub(crate) use self::permissions_menu::auto_review_available;
|
||||
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
|
||||
mod backend_banners;
|
||||
mod protocol;
|
||||
mod protocol_requests;
|
||||
mod rate_limits;
|
||||
@@ -594,9 +595,11 @@ pub(crate) struct ChatWidget {
|
||||
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
codex_spend_control_reached: Option<bool>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
backend_banner_state: backend_banners::BackendBannerState,
|
||||
backend_banner_notice_model: Option<String>,
|
||||
warning_display_state: WarningDisplayState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
add_credits_nudge_email_in_flight: Option<AddCreditsNudgeCreditType>,
|
||||
add_credits_nudge_email_in_flight: Option<rate_limits::PendingCreditsNudge>,
|
||||
adaptive_chunking: AdaptiveChunkingPolicy,
|
||||
// Stream lifecycle controller
|
||||
stream_controller: Option<StreamController>,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Account banner state, independent of the current model and the lifetime of its rendered view.
|
||||
//!
|
||||
//! Only explicitly dismissible, previously shown occurrences can be dismissed. Model changes hide
|
||||
//! content without dismissing it; an authoritative replacement/absence starts a new occurrence.
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::backend_banners::BackendBanner;
|
||||
use crate::backend_banners::BannerPresentation;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BackendBannerState {
|
||||
banner: Option<BackendBanner>,
|
||||
presented: Option<BackendBanner>,
|
||||
shown: bool,
|
||||
dismissed: bool,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn backend_banner_fallback(&self) -> Option<ModelPreset> {
|
||||
let banner = self.backend_banner_state.banner.as_ref()?;
|
||||
if !self.has_chatgpt_account
|
||||
|| !self.config.model_provider.requires_openai_auth
|
||||
|| banner.blocked_model_slug.as_deref() != Some(self.current_model())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let models = self.model_catalog.try_list_models().ok()?;
|
||||
banner.fallback_model_slugs.iter().find_map(|candidate| {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| {
|
||||
model.show_in_picker
|
||||
&& model.model == *candidate
|
||||
&& model.model != self.current_model()
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Commit a successfully applied task setting and keep its recovery notice visible together.
|
||||
pub(crate) fn finish_backend_banner_fallback(&mut self, mode: CollaborationMode) {
|
||||
self.set_model(mode.model());
|
||||
self.set_reasoning_effort(mode.reasoning_effort());
|
||||
if mode.mode == ModeKind::Plan {
|
||||
self.set_plan_mode_reasoning_effort(mode.reasoning_effort());
|
||||
}
|
||||
self.set_effective_collaboration_mode(mode);
|
||||
// Showing the post-switch notice is distinct from having seen the original blocked state.
|
||||
self.backend_banner_state.shown = false;
|
||||
self.backend_banner_state.dismissed = false;
|
||||
self.backend_banner_notice_model = Some(self.current_model().to_string());
|
||||
self.refresh_backend_banner_visibility();
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_backend_banner_state(&mut self, previous: &mut ChatWidget) {
|
||||
previous.observe_backend_banner_view();
|
||||
self.backend_banner_state = std::mem::take(&mut previous.backend_banner_state);
|
||||
self.backend_banner_state.presented = None;
|
||||
self.refresh_backend_banner_visibility();
|
||||
}
|
||||
|
||||
pub(crate) fn update_backend_banner(&mut self, response: &GetAccountRateLimitsResponse) {
|
||||
self.observe_backend_banner_view();
|
||||
let banner = response
|
||||
.rate_limit_upsell
|
||||
.as_ref()
|
||||
.and_then(BackendBanner::parse)
|
||||
.map(|mut banner| {
|
||||
banner.account_id = response.account_id.clone().unwrap_or_default();
|
||||
banner.plan_type = response.rate_limits.plan_type;
|
||||
banner
|
||||
});
|
||||
let same_occurrence = self
|
||||
.backend_banner_state
|
||||
.banner
|
||||
.as_ref()
|
||||
.zip(banner.as_ref())
|
||||
.is_some_and(|(old, new)| {
|
||||
old.account_id == new.account_id
|
||||
&& old.banner_type == new.banner_type
|
||||
&& old.reset_at == new.reset_at
|
||||
&& old.presentation == new.presentation
|
||||
&& old.blocked_model_slug.as_ref().or(old.model_slug.as_ref())
|
||||
== new.blocked_model_slug.as_ref().or(new.model_slug.as_ref())
|
||||
&& old.fallback_model_slugs == new.fallback_model_slugs
|
||||
});
|
||||
if !same_occurrence {
|
||||
self.backend_banner_state.shown = false;
|
||||
self.backend_banner_state.dismissed = false;
|
||||
self.backend_banner_notice_model = None;
|
||||
}
|
||||
self.backend_banner_state.banner = banner;
|
||||
self.refresh_backend_banner_visibility();
|
||||
}
|
||||
|
||||
fn observe_backend_banner_view(&mut self) {
|
||||
let (shown, dismissed) = self.bottom_pane.inline_banner_lifecycle();
|
||||
self.backend_banner_state.shown |= shown;
|
||||
self.backend_banner_state.dismissed |= dismissed;
|
||||
}
|
||||
|
||||
pub(super) fn refresh_backend_banner_visibility(&mut self) {
|
||||
let banner = self.backend_banner_state.banner.as_ref().filter(|banner| {
|
||||
// Explicit fallback payloads describe the selected replacement, not a pending switch.
|
||||
let matches_selected_model = match banner.blocked_model_slug.as_deref() {
|
||||
Some(blocked) if !banner.fallback_model_slugs.is_empty() => {
|
||||
blocked != self.current_model()
|
||||
&& banner
|
||||
.fallback_model_slugs
|
||||
.iter()
|
||||
.any(|model| model == self.current_model())
|
||||
}
|
||||
Some(_) | None => {
|
||||
banner
|
||||
.model_slug
|
||||
.as_deref()
|
||||
.is_none_or(|model| model == self.current_model())
|
||||
|| self.backend_banner_notice_model.as_deref() == Some(self.current_model())
|
||||
}
|
||||
};
|
||||
!self.backend_banner_state.dismissed && matches_selected_model
|
||||
});
|
||||
if banner == self.backend_banner_state.presented.as_ref() {
|
||||
return;
|
||||
}
|
||||
let content = banner.map(BackendBanner::actionable_banner);
|
||||
self.backend_banner_state.presented = banner.cloned();
|
||||
if content.is_some() {
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(super::rate_limits::WORKSPACE_NUDGE_VIEW_ID);
|
||||
if self
|
||||
.bottom_pane
|
||||
.dismiss_view_by_id(super::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID)
|
||||
{
|
||||
// Replacing the view is not the user's decision to dismiss the prompt.
|
||||
self.rate_limit_switch_prompt = super::RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
}
|
||||
self.bottom_pane.set_inline_banner(content);
|
||||
if self.backend_banner_state.presented.is_none()
|
||||
&& !self.bottom_pane.is_task_running()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
{
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_backend_banner_view(&mut self) {
|
||||
self.observe_backend_banner_view();
|
||||
self.refresh_backend_banner_visibility();
|
||||
}
|
||||
|
||||
pub(super) fn dismiss_backend_banner_for_new_turn(&mut self) {
|
||||
self.observe_backend_banner_view();
|
||||
if self.backend_banner_state.shown
|
||||
&& self
|
||||
.backend_banner_state
|
||||
.presented
|
||||
.as_ref()
|
||||
.is_some_and(|banner| banner.presentation == BannerPresentation::Dismissible)
|
||||
{
|
||||
self.backend_banner_state.dismissed = true;
|
||||
self.refresh_backend_banner_visibility();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_applicable_backend_banner(&self) -> bool {
|
||||
!self.backend_banner_state.dismissed && self.backend_banner_state.presented.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_backend_banner(&mut self) {
|
||||
self.backend_banner_state = BackendBannerState::default();
|
||||
self.backend_banner_notice_model = None;
|
||||
self.bottom_pane.set_inline_banner(/*banner*/ None);
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,8 @@ impl ChatWidget {
|
||||
codex_rate_limit_reached_type: None,
|
||||
codex_spend_control_reached: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
backend_banner_state: backend_banners::BackendBannerState::default(),
|
||||
backend_banner_notice_model: None,
|
||||
warning_display_state: WarningDisplayState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
add_credits_nudge_email_in_flight: None,
|
||||
|
||||
@@ -32,6 +32,7 @@ impl ChatWidget {
|
||||
let should_submit_now = self.is_session_configured()
|
||||
&& !self.is_plan_streaming_in_tui()
|
||||
&& !self.input_queue.suppress_queue_autosend
|
||||
&& !self.input_queue.rate_limit_recovery_pending
|
||||
&& (!self.input_queue.user_turn_pending_start
|
||||
|| self.turn_lifecycle.agent_turn_running);
|
||||
if should_submit_now {
|
||||
@@ -114,7 +115,8 @@ impl ChatWidget {
|
||||
}
|
||||
let should_run_now = self.is_session_configured()
|
||||
&& !self.is_user_turn_pending_or_running()
|
||||
&& !self.input_queue.suppress_queue_autosend;
|
||||
&& !self.input_queue.suppress_queue_autosend
|
||||
&& !self.input_queue.rate_limit_recovery_pending;
|
||||
if !should_run_now || action != QueuedInputAction::Plain {
|
||||
self.input_queue
|
||||
.queued_user_messages
|
||||
@@ -140,6 +142,7 @@ impl ChatWidget {
|
||||
if !self.is_session_configured()
|
||||
|| self.misalignment_policy_violation
|
||||
|| self.input_queue.suppress_queue_autosend
|
||||
|| self.input_queue.rate_limit_recovery_pending
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub(super) struct InputQueueState {
|
||||
/// fresh user turn instead of restoring them into the composer.
|
||||
pub(super) submit_pending_steers_after_interrupt: bool,
|
||||
pub(super) suppress_queue_autosend: bool,
|
||||
/// Hold submissions while a usage failure or backend-directed model fallback is resolved.
|
||||
pub(super) rate_limit_recovery_pending: bool,
|
||||
}
|
||||
|
||||
impl InputQueueState {
|
||||
@@ -57,6 +59,7 @@ impl InputQueueState {
|
||||
self.rejected_steer_history_records.clear();
|
||||
self.pending_steers.clear();
|
||||
self.submit_pending_steers_after_interrupt = false;
|
||||
self.rate_limit_recovery_pending = false;
|
||||
}
|
||||
|
||||
pub(super) fn preview(&self) -> PendingInputPreview {
|
||||
|
||||
@@ -118,7 +118,8 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn submit_initial_user_message_if_pending(&mut self) {
|
||||
if self.suppress_initial_user_message_submit {
|
||||
if self.suppress_initial_user_message_submit || self.input_queue.rate_limit_recovery_pending
|
||||
{
|
||||
return;
|
||||
}
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
|
||||
@@ -114,6 +114,16 @@ impl ChatWidget {
|
||||
if self.misalignment_policy_violation {
|
||||
return (false, None);
|
||||
}
|
||||
if self.input_queue.rate_limit_recovery_pending {
|
||||
self.input_queue
|
||||
.queued_user_messages
|
||||
.push_back(QueuedUserMessage::from(user_message));
|
||||
self.input_queue
|
||||
.queued_user_message_history_records
|
||||
.push_back(history_record);
|
||||
self.refresh_pending_input_preview();
|
||||
return (true, None);
|
||||
}
|
||||
if !self.is_session_configured() {
|
||||
tracing::warn!("cannot submit user message before session is configured; queueing");
|
||||
self.input_queue
|
||||
@@ -394,6 +404,7 @@ impl ChatWidget {
|
||||
if !self.submit_op(op.clone()) {
|
||||
return (false, None);
|
||||
}
|
||||
self.dismiss_backend_banner_for_new_turn();
|
||||
if render_in_history {
|
||||
self.input_queue.user_turn_pending_start = true;
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ impl ChatWidget {
|
||||
self.flush_completed_command_activity();
|
||||
}
|
||||
let input_result = self.bottom_pane.handle_key_event(key_event);
|
||||
self.sync_backend_banner_view();
|
||||
if should_pause_active_goal {
|
||||
self.pause_active_goal_for_interrupt();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`.
|
||||
|
||||
use super::*;
|
||||
pub(super) const WORKSPACE_NUDGE_VIEW_ID: &str = "workspace-usage-nudge";
|
||||
use crate::bottom_pane::ActionableBanner;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) struct PendingCreditsNudge {
|
||||
request_id: Uuid,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
}
|
||||
|
||||
pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.6-luna";
|
||||
pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
|
||||
@@ -162,11 +170,31 @@ fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool {
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn hold_rate_limit_recovery(&mut self) -> bool {
|
||||
std::mem::replace(&mut self.input_queue.rate_limit_recovery_pending, true)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_recovery(&mut self) {
|
||||
if std::mem::take(&mut self.input_queue.rate_limit_recovery_pending) {
|
||||
self.submit_initial_user_message_if_pending();
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);
|
||||
}
|
||||
|
||||
pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) {
|
||||
if let (Some(previous), Some(current)) = (
|
||||
self.codex_rate_limit_reached_type,
|
||||
snapshot.rate_limit_reached_type,
|
||||
) && previous != current
|
||||
{
|
||||
// Do not keep a credits CTA when a newer notification requires a different remedy.
|
||||
// An absent aggregate limit type is not a contradiction of model-scoped recovery.
|
||||
self.clear_backend_banner();
|
||||
}
|
||||
// Rolling app-server notifications are sparse. Preserve metadata learned from the full read.
|
||||
self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate);
|
||||
}
|
||||
@@ -347,7 +375,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) {
|
||||
if self.rate_limit_switch_prompt_hidden() {
|
||||
if self.has_applicable_backend_banner() {
|
||||
return;
|
||||
}
|
||||
if self.rate_limit_switch_prompt_hidden() || self.current_model() == NUDGE_MODEL_SLUG {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
return;
|
||||
}
|
||||
@@ -482,11 +513,11 @@ impl ChatWidget {
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some(title.to_string()),
|
||||
subtitle: Some(prompt.to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
self.bottom_pane.show_actionable_banner(ActionableBanner {
|
||||
view_id: Some(WORKSPACE_NUDGE_VIEW_ID),
|
||||
title: title.to_string(),
|
||||
description: prompt.to_string(),
|
||||
actions: items,
|
||||
initial_selected_idx: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -495,19 +526,32 @@ impl ChatWidget {
|
||||
pub(crate) fn start_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
) -> bool {
|
||||
self.add_credits_nudge_email_in_flight = Some(credit_type);
|
||||
true
|
||||
) -> Option<Uuid> {
|
||||
if self.add_credits_nudge_email_in_flight.is_some() {
|
||||
return None;
|
||||
}
|
||||
let request_id = Uuid::new_v4();
|
||||
self.add_credits_nudge_email_in_flight = Some(PendingCreditsNudge {
|
||||
request_id,
|
||||
credit_type,
|
||||
});
|
||||
Some(request_id)
|
||||
}
|
||||
|
||||
pub(crate) fn finish_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
request_id: Uuid,
|
||||
result: Result<AddCreditsNudgeEmailStatus, String>,
|
||||
) {
|
||||
let credit_type = self
|
||||
let Some(pending) = self
|
||||
.add_credits_nudge_email_in_flight
|
||||
.take()
|
||||
.unwrap_or(AddCreditsNudgeCreditType::Credits);
|
||||
.as_ref()
|
||||
.filter(|pending| pending.request_id == request_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let credit_type = pending.credit_type;
|
||||
self.add_credits_nudge_email_in_flight = None;
|
||||
let message = match (credit_type, result) {
|
||||
(AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => {
|
||||
"Workspace owner notified."
|
||||
|
||||
@@ -25,6 +25,7 @@ impl ChatWidget {
|
||||
self.bottom_pane
|
||||
.set_queue_submissions(/*queue_submissions*/ false);
|
||||
if previous_thread_id != self.thread_id {
|
||||
self.backend_banner_notice_model = None;
|
||||
self.pending_automatic_thread_names.clear();
|
||||
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
|
||||
self.clear_thread_usage_state();
|
||||
|
||||
@@ -220,6 +220,9 @@ impl ChatWidget {
|
||||
self.invalidate_connector_scope();
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
self.clear_backend_banner();
|
||||
self.input_queue.rate_limit_recovery_pending = false;
|
||||
self.add_credits_nudge_email_in_flight = None;
|
||||
self.codex_rate_limit_reached_type = None;
|
||||
self.codex_spend_control_reached = None;
|
||||
self.rate_limit_warnings = RateLimitWarningState::default();
|
||||
@@ -446,6 +449,7 @@ impl ChatWidget {
|
||||
/// header/title (`refresh_model_display`) but forget the footer status line
|
||||
/// (`refresh_status_line`).
|
||||
pub(super) fn refresh_model_dependent_surfaces(&mut self) {
|
||||
self.sync_backend_banner_view();
|
||||
self.refresh_model_display();
|
||||
self.refresh_status_line();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,8 @@ fn next_goal_draft(
|
||||
|
||||
mod app_server;
|
||||
mod approval_requests;
|
||||
#[path = "tests/backend_banners_tests.rs"]
|
||||
mod backend_banners_tests;
|
||||
mod composer_submission;
|
||||
#[path = "tests/config_errors_tests.rs"]
|
||||
mod config_errors;
|
||||
@@ -252,6 +254,8 @@ mod plan_mode;
|
||||
#[path = "tests/plugin_catalog_tests.rs"]
|
||||
mod plugin_catalog;
|
||||
mod popups_and_settings;
|
||||
#[path = "tests/rate_limit_recovery_tests.rs"]
|
||||
mod rate_limit_recovery_tests;
|
||||
mod review_mode;
|
||||
mod side;
|
||||
mod slash_commands;
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn banner_response(
|
||||
presentation: Option<&str>,
|
||||
ctas: serde_json::Value,
|
||||
) -> GetAccountRateLimitsResponse {
|
||||
let mut banner = json!({
|
||||
"banner_type": "selected_model_limit", "model_slug": "test-model-a",
|
||||
"title": "Selected model usage exhausted",
|
||||
"description": "Ask your owner for credits or switch to another model.", "ctas": ctas,
|
||||
});
|
||||
if let Some(presentation) = presentation {
|
||||
banner["presentation"] = json!(presentation);
|
||||
}
|
||||
GetAccountRateLimitsResponse {
|
||||
account_id: Some("workspace-a".into()),
|
||||
rate_limit_upsell: Some(banner),
|
||||
rate_limits: snapshot(/*percent*/ 25.0),
|
||||
rate_limits_by_limit_id: None,
|
||||
rate_limit_reset_credits: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_presentation_and_cta_do_not_imply_recovery() {
|
||||
for presentation in [None, Some("inline"), Some("dismissible")] {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let response = banner_response(
|
||||
presentation,
|
||||
json!([{"action":"notify_owner","label":"Notify owner"}]),
|
||||
);
|
||||
chat.update_backend_banner(&response);
|
||||
let initial = render_bottom_popup(&chat, /*width*/ 70);
|
||||
assert!(initial.contains("Selected model usage exhausted"));
|
||||
assert_eq!(
|
||||
initial.contains("esc to dismiss"),
|
||||
presentation == Some("dismissible")
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
rx.try_recv()
|
||||
.is_ok_and(|event| matches!(event, AppEvent::SendAddCreditsNudgeEmail { .. }))
|
||||
);
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 70), initial);
|
||||
let request_id = chat
|
||||
.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.expect("start notification");
|
||||
if presentation == Some("inline") {
|
||||
insta::assert_snapshot!(
|
||||
"backend_banner_notification",
|
||||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 70))
|
||||
);
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
rx.try_recv()
|
||||
.is_ok_and(|event| matches!(event, AppEvent::SendAddCreditsNudgeEmail { .. }))
|
||||
);
|
||||
assert_eq!(chat.composer_text_with_pending(), "");
|
||||
assert!(
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.is_none()
|
||||
);
|
||||
chat.finish_add_credits_nudge_email_request(request_id, Err("local failure".into()));
|
||||
chat.update_backend_banner(&response);
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 70), initial);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"),
|
||||
presentation != Some("dismissible")
|
||||
);
|
||||
chat.set_model("test-model-b");
|
||||
assert!(!chat.has_applicable_backend_banner());
|
||||
assert!(
|
||||
!render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
chat.set_model("test-model-a");
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"),
|
||||
presentation != Some("dismissible")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_zero_actions_preserve_guidance_and_composer_input() {
|
||||
for presentation in [None, Some("inline"), Some("dismissible")] {
|
||||
for ctas in [
|
||||
json!([]),
|
||||
json!([{"action":"future_action","label":"Unsupported"}]),
|
||||
] {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
chat.update_backend_banner(&banner_response(presentation, ctas));
|
||||
let rendered = render_bottom_popup(&chat, /*width*/ 70);
|
||||
assert!(rendered.contains("Selected model usage exhausted"));
|
||||
assert!(rendered.contains("switch to another model"));
|
||||
assert!(!rendered.to_lowercase().contains("no matches"));
|
||||
assert!(!rendered.contains("Press a number"));
|
||||
assert_eq!(
|
||||
rendered.contains("esc to dismiss"),
|
||||
presentation == Some("dismissible")
|
||||
);
|
||||
if presentation == Some("dismissible") {
|
||||
insta::assert_snapshot!(
|
||||
"backend_banner_information_dismissible",
|
||||
normalize_snapshot_paths(rendered)
|
||||
);
|
||||
} else {
|
||||
insta::assert_snapshot!(
|
||||
"backend_banner_information_only",
|
||||
normalize_snapshot_paths(rendered)
|
||||
);
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||||
// An ordinary navigation key flushes the composer's pending first-character buffer.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
assert_eq!(chat.composer_text_with_pending(), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_dismissal_tracks_occurrence_not_copy_or_model() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let mut response = banner_response(Some("dismissible"), json!([]));
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
chat.set_model("test-model-b");
|
||||
response.rate_limit_upsell.as_mut().unwrap()["description"] = json!("Updated backend copy");
|
||||
chat.update_backend_banner(&response);
|
||||
chat.set_model("test-model-a");
|
||||
assert!(!render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy"));
|
||||
response.rate_limit_upsell.as_mut().unwrap()["reset_at"] = json!(2000000000);
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy"));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let mut absent = response.clone();
|
||||
absent.rate_limit_upsell = None;
|
||||
chat.update_backend_banner(&absent);
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Updated backend copy"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_occurrence_uses_explicit_or_legacy_source_model() {
|
||||
for explicit_source in [false, true] {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let mut response = banner_response(Some("dismissible"), json!([]));
|
||||
if explicit_source {
|
||||
response.rate_limit_upsell.as_mut().unwrap()["blocked_model_slug"] =
|
||||
json!("test-model-a");
|
||||
}
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
response.rate_limit_upsell.as_mut().unwrap()["model_slug"] = json!("test-model-b");
|
||||
chat.update_backend_banner(&response);
|
||||
chat.set_model("test-model-b");
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"),
|
||||
!explicit_source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_turn_on_another_model_preserves_hidden_occurrence() {
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_backend_banner(&banner_response(Some("dismissible"), json!([])));
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"));
|
||||
chat.set_model("test-model-b");
|
||||
assert!(!render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"));
|
||||
chat.submit_user_message(UserMessage::from("hello"));
|
||||
assert!(
|
||||
ops.try_recv()
|
||||
.is_ok_and(|op| matches!(op, Op::UserTurn { .. }))
|
||||
);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
chat.set_model("test-model-a");
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_restores_only_programmatically_displaced_switch_prompt() {
|
||||
for user_dismissed in [false, true] {
|
||||
let (mut chat, _rx, _ops) = 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!(render_bottom_popup(&chat, /*width*/ 90).contains("Approaching rate limits"));
|
||||
if user_dismissed {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
}
|
||||
let mut response = banner_response(Some("inline"), json!([]));
|
||||
response.rate_limit_upsell.as_mut().unwrap()["model_slug"] = json!("gpt-5");
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 90).contains("Selected model usage exhausted")
|
||||
);
|
||||
response.rate_limit_upsell = None;
|
||||
chat.update_backend_banner(&response);
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 90).contains("Approaching rate limits"),
|
||||
!user_dismissed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_notification_completion_cannot_cross_account_change() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let previous = chat
|
||||
.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.expect("start previous account request");
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
let current = chat
|
||||
.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit)
|
||||
.expect("new account must be actionable");
|
||||
drain_insert_history(&mut rx);
|
||||
chat.finish_add_credits_nudge_email_request(previous, Ok(AddCreditsNudgeEmailStatus::Sent));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit)
|
||||
.is_none()
|
||||
);
|
||||
chat.finish_add_credits_nudge_email_request(current, Ok(AddCreditsNudgeEmailStatus::Sent));
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
insta::assert_snapshot!("owner_notification_after_account_change", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_new_turn_dismisses_only_shown_dismissible_content() {
|
||||
for (presentation, show_before_submit) in [
|
||||
(None, true),
|
||||
(Some("inline"), true),
|
||||
(Some("dismissible"), true),
|
||||
(Some("dismissible"), false),
|
||||
] {
|
||||
let (mut chat, _rx, mut ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_backend_banner(&banner_response(presentation, json!([])));
|
||||
if show_before_submit {
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
}
|
||||
chat.submit_user_message(UserMessage::from("hello"));
|
||||
assert!(
|
||||
ops.try_recv()
|
||||
.is_ok_and(|op| matches!(op, Op::UserTurn { .. }))
|
||||
);
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"),
|
||||
!(presentation == Some("dismissible") && show_before_submit)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_invalid_content_and_absence_restore_fallback() {
|
||||
for replacement in [
|
||||
serde_json::Value::Null,
|
||||
json!({"presentation":"future_mode"}),
|
||||
json!({"presentation":null}),
|
||||
json!({"title":" "}),
|
||||
] {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let mut response = banner_response(Some("inline"), json!([]));
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
if replacement.is_null() {
|
||||
response.rate_limit_upsell = None;
|
||||
} else {
|
||||
for (key, value) in replacement.as_object().unwrap() {
|
||||
response.rate_limit_upsell.as_mut().unwrap()[key.as_str()] = value.clone();
|
||||
}
|
||||
}
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(!chat.has_applicable_backend_banner());
|
||||
chat.codex_rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::Generic, "limit".into());
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 90).contains("workspace owner"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_fallback_candidates_and_notice_follow_selected_model() {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
let template = chat.model_catalog.try_list_models().unwrap()[0].clone();
|
||||
let models = ["hidden-model", "test-model-b", "test-model-c"].map(|model| ModelPreset {
|
||||
model: model.into(),
|
||||
show_in_picker: model != "hidden-model",
|
||||
..template.clone()
|
||||
});
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(models.to_vec()));
|
||||
let mut response = banner_response(Some("inline"), json!([]));
|
||||
response.rate_limit_upsell.as_mut().unwrap()["blocked_model_slug"] = json!("test-model-a");
|
||||
response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] =
|
||||
json!(["hidden-model", "test-model-c", "test-model-b"]);
|
||||
chat.update_backend_banner(&response);
|
||||
assert_eq!(chat.backend_banner_fallback(), Some(models[2].clone()));
|
||||
// A retained ChatGPT login must not change a task using a custom provider.
|
||||
chat.config.model_provider.requires_openai_auth = false;
|
||||
assert_eq!(chat.backend_banner_fallback(), None);
|
||||
chat.config.model_provider.requires_openai_auth = true;
|
||||
let mut mode = chat.effective_collaboration_mode();
|
||||
mode.settings.model = "test-model-c".into();
|
||||
chat.finish_backend_banner_fallback(mode);
|
||||
let before = render_bottom_popup(&chat, /*width*/ 70);
|
||||
assert!(before.contains("Selected model usage exhausted"));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted"));
|
||||
assert_eq!(chat.backend_banner_fallback(), None);
|
||||
let (mut other_task, _events, _ops) = make_chatwidget_manual(Some("test-model-c")).await;
|
||||
other_task.inherit_backend_banner_state(&mut chat);
|
||||
assert!(
|
||||
render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
other_task.set_model("test-model-a");
|
||||
assert!(
|
||||
!render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
other_task.set_model("unrelated-model");
|
||||
assert!(
|
||||
!render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
other_task.set_model("test-model-c");
|
||||
assert!(
|
||||
render_bottom_popup(&other_task, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_sparse_updates_preserve_visible_and_dismissed_occurrences() {
|
||||
for dismiss in [false, true] {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
chat.update_backend_banner(&banner_response(Some("dismissible"), json!([])));
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 70).contains("Selected model usage exhausted")
|
||||
);
|
||||
if dismiss {
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 70);
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 60.0));
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 70), before);
|
||||
// A model-scoped banner can coexist with an absent aggregate reached type.
|
||||
let mut exhausted = snapshot(/*percent*/ 100.0);
|
||||
exhausted.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rolling_rate_limit_snapshot(exhausted);
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 70), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_banner_changed_remedy_keeps_fallback_until_applicable_replacement() {
|
||||
for rolling_update in [false, true] {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let mut credits = snapshot(/*percent*/ 100.0);
|
||||
credits.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_snapshot(Some(credits));
|
||||
let mut response = banner_response(
|
||||
Some("inline"),
|
||||
json!([{"action":"notify_owner","label":"Notify owner"}]),
|
||||
);
|
||||
chat.update_backend_banner(&response);
|
||||
assert!(render_bottom_popup(&chat, /*width*/ 90).contains("Notify owner"));
|
||||
let mut cap = snapshot(/*percent*/ 100.0);
|
||||
cap.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
if rolling_update {
|
||||
chat.on_rolling_rate_limit_snapshot(cap);
|
||||
}
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage cap reached".into());
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 90)
|
||||
.contains("Request a limit increase from your owner")
|
||||
);
|
||||
response.rate_limit_upsell.as_mut().unwrap()["title"] =
|
||||
json!("Workspace spending cap reached");
|
||||
response.rate_limit_upsell.as_mut().unwrap()["ctas"] =
|
||||
json!([{"action":"request_increase","label":"Request more usage"}]);
|
||||
chat.update_backend_banner(&response);
|
||||
let rendered = render_bottom_popup(&chat, /*width*/ 90);
|
||||
assert!(
|
||||
rendered.contains("Workspace spending cap reached"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("Request more usage"));
|
||||
assert!(!rendered.contains("Request a limit increase from your owner"));
|
||||
chat.set_model("test-model-b");
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage cap reached".into());
|
||||
assert!(
|
||||
render_bottom_popup(&chat, /*width*/ 90)
|
||||
.contains("Request a limit increase from your owner")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
|
||||
pub(crate) fn normalize_snapshot_paths(text: impl Into<String>) -> String {
|
||||
let mut text = text.into();
|
||||
|
||||
for unix_path in ["/tmp/project", "/tmp/hooks.json"] {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_recovery_holds_submissions_until_model_change() {
|
||||
let (mut chat, mut events, mut ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
handle_turn_started(&mut chat, "failed-turn");
|
||||
chat.queue_user_message(UserMessage::from("queued follow-up"));
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into());
|
||||
assert!(
|
||||
std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(
|
||||
event,
|
||||
AppEvent::RefreshRateLimits {
|
||||
origin: crate::app_event::RateLimitRefreshOrigin::Recovery
|
||||
}
|
||||
))
|
||||
);
|
||||
chat.submit_user_message(UserMessage::from("submitted during recovery"));
|
||||
assert_no_submit_op(&mut ops);
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["queued follow-up", "submitted during recovery"]
|
||||
);
|
||||
|
||||
chat.set_model("test-model-b");
|
||||
chat.finish_rate_limit_recovery();
|
||||
let Op::UserTurn { model, items, .. } = next_submit_op(&mut ops) else {
|
||||
panic!("expected queued follow-up on the fallback model");
|
||||
};
|
||||
assert_eq!(model, "test-model-b");
|
||||
assert!(
|
||||
matches!(items.as_slice(), [UserInput::Text { text, .. }] if text == "queued follow-up")
|
||||
);
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["submitted during recovery"]
|
||||
);
|
||||
chat.finish_rate_limit_recovery();
|
||||
assert_no_submit_op(&mut ops);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_recovery_preserves_settings_hold_and_clears_on_account_change() {
|
||||
let (mut chat, _events, mut ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into());
|
||||
chat.queue_user_message(UserMessage::from("queued follow-up"));
|
||||
chat.finish_rate_limit_recovery();
|
||||
assert_no_submit_op(&mut ops);
|
||||
assert!(chat.input_queue.suppress_queue_autosend);
|
||||
assert!(!chat.input_queue.rate_limit_recovery_pending);
|
||||
|
||||
chat.on_rate_limit_error(RateLimitErrorKind::UsageLimit, "Usage exhausted".into());
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
|
||||
);
|
||||
assert!(!chat.input_queue.rate_limit_recovery_pending);
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["queued follow-up"]);
|
||||
assert_no_submit_op(&mut ops);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/backend_banners_tests.rs
|
||||
assertion_line: 66
|
||||
expression: rendered
|
||||
---
|
||||
Selected model usage exhausted
|
||||
Ask your owner for credits or switch to another model.
|
||||
|
||||
esc to dismiss · type to continue
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
test-model-a default · /tmp/project
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/backend_banners_tests.rs
|
||||
assertion_line: 62
|
||||
expression: rendered
|
||||
---
|
||||
Selected model usage exhausted
|
||||
Ask your owner for credits or switch to another model.
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
test-model-a default · /tmp/project
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/backend_banners_tests.rs
|
||||
assertion_line: 50
|
||||
expression: "render_bottom_popup(&chat, 70)"
|
||||
---
|
||||
Selected model usage exhausted
|
||||
Ask your owner for credits or switch to another model.
|
||||
|
||||
› 1. Notify owner
|
||||
|
||||
Press a number to choose
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
test-model-a default · /tmp/project
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/backend_banners_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Limit increase requested.
|
||||
@@ -1918,8 +1918,10 @@ async fn workspace_owner_credits_nudge_completion_renders_feedback() {
|
||||
let mut rendered_cases = Vec::new();
|
||||
for (result, expected) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits);
|
||||
chat.finish_add_credits_nudge_email_request(result);
|
||||
let request_id = chat
|
||||
.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.expect("start notification");
|
||||
chat.finish_add_credits_nudge_email_request(request_id, result);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
@@ -1954,14 +1956,24 @@ async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() {
|
||||
let mut rendered_cases = Vec::new();
|
||||
for (result, expected) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit);
|
||||
chat.finish_add_credits_nudge_email_request(result);
|
||||
let request_id = chat
|
||||
.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit)
|
||||
.expect("start notification");
|
||||
assert!(
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.is_none()
|
||||
);
|
||||
chat.finish_add_credits_nudge_email_request(request_id, result);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains(expected), "rendered: {rendered}");
|
||||
rendered_cases.push(rendered);
|
||||
assert!(
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
|
||||
@@ -398,6 +398,8 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(super) fn on_rate_limit_error(&mut self, error_kind: RateLimitErrorKind, message: String) {
|
||||
// on_error can drain queued input, before the asynchronous recovery read completes.
|
||||
self.input_queue.rate_limit_recovery_pending = self.has_chatgpt_account;
|
||||
let usage_limit_error = matches!(error_kind, RateLimitErrorKind::UsageLimit);
|
||||
let rate_limit_reached_type = self.codex_rate_limit_reached_type.map(|kind| {
|
||||
if usage_limit_error {
|
||||
@@ -414,31 +416,38 @@ impl ChatWidget {
|
||||
kind
|
||||
}
|
||||
});
|
||||
if self.codex_rate_limit_reached_type != rate_limit_reached_type {
|
||||
self.clear_backend_banner();
|
||||
}
|
||||
self.codex_rate_limit_reached_type = rate_limit_reached_type;
|
||||
match rate_limit_reached_type {
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => {
|
||||
self.on_error(
|
||||
// Keep owner remediation in history even when the optional backend banner is unavailable.
|
||||
let (message, nudge) = match rate_limit_reached_type {
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted) => (
|
||||
"You're out of credits. Your workspace is out of credits. Add credits to continue using Codex."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => {
|
||||
self.on_error(
|
||||
None,
|
||||
),
|
||||
Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached) => (
|
||||
"Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) => {
|
||||
self.on_error(message);
|
||||
self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::Credits);
|
||||
}
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) => {
|
||||
self.on_error(message);
|
||||
self.open_workspace_owner_nudge_prompt(AddCreditsNudgeCreditType::UsageLimit);
|
||||
}
|
||||
Some(RateLimitReachedType::RateLimitReached) | None => {
|
||||
self.on_error(message);
|
||||
}
|
||||
None,
|
||||
),
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) =>
|
||||
(message, Some(AddCreditsNudgeCreditType::Credits)),
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached) =>
|
||||
(message, Some(AddCreditsNudgeCreditType::UsageLimit)),
|
||||
Some(RateLimitReachedType::RateLimitReached) | None => (message, None),
|
||||
};
|
||||
self.on_error(message);
|
||||
if !self.has_applicable_backend_banner()
|
||||
&& let Some(credit_type) = nudge
|
||||
{
|
||||
self.open_workspace_owner_nudge_prompt(credit_type);
|
||||
}
|
||||
if self.has_chatgpt_account {
|
||||
self.app_event_tx.send(AppEvent::RefreshRateLimits {
|
||||
origin: crate::app_event::RateLimitRefreshOrigin::Recovery,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ mod app_server_approval_conversions;
|
||||
mod app_server_session;
|
||||
mod approval_events;
|
||||
mod ascii_animation;
|
||||
mod backend_banners;
|
||||
mod bottom_pane;
|
||||
mod branch_summary;
|
||||
mod chatwidget;
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) use card::new_status_output_with_rate_limits;
|
||||
pub(crate) use card::new_status_output_with_rate_limits_handle;
|
||||
pub(crate) use helpers::compose_agents_summary;
|
||||
pub(crate) use helpers::format_directory_display;
|
||||
pub(crate) use helpers::format_reset_timestamp;
|
||||
pub(crate) use helpers::format_tokens_compact;
|
||||
pub(crate) use helpers::plan_type_display_name;
|
||||
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
|
||||
|
||||
Reference in New Issue
Block a user