Files
codex/codex-rs/core/src/state/session_tests.rs
T
efrazer-oai c8e5db16c9 feat: show enterprise monthly credit limits in status (#24812)
## Summary

Enterprise users can have an effective monthly credit limit, but Codex
`/status` currently drops that metadata from the account-usage response.

This change adds the optional `spend_control.individual_limit`
projection to the existing rate-limit snapshot flow. The backend client
reads the monthly limit, app-server exposes it as `individualLimit`, and
the TUI renders a `Monthly credit limit` row through the existing
progress-bar renderer.

When the backend does not return an effective monthly limit, existing
rate-limit behavior is unchanged.

## Existing backend state

The account-usage backend already returns the effective monthly limit
and current usage together:

```json
{
  "spend_control": {
    "reached": false,
    "individual_limit": {
      "limit": "25000",
      "used": "8000",
      "remaining": "17000",
      "used_percent": 32,
      "remaining_percent": 68,
      "reset_after_seconds": 86400,
      "reset_at": 1778137680
    }
  }
}
```

Before this change, Codex projected rolling `primary` and `secondary`
windows plus `credits`. It ignored `spend_control.individual_limit`, so
app-server clients and `/status` could not render the monthly cap.

The updated flow is:

```text
account usage backend
  -> backend-client reads spend_control.individual_limit
  -> existing rate-limit snapshot carries optional individual_limit
  -> app-server exposes optional individualLimit
  -> TUI renders Monthly credit limit
```

## App-server contract

`account/rateLimits/read` and sparse `account/rateLimits/updated`
notifications now include an additive nullable
`rateLimits.individualLimit` field:

```json
{
  "individualLimit": {
    "limit": "25000",
    "used": "8000",
    "remainingPercent": 68,
    "resetsAt": 1778137680
  }
}
```

In an `account/rateLimits/read` response, `null` means no monthly limit
is available. `account/rateLimits/updated` remains a sparse rolling
notification: clients merge available values into their most recent
`account/rateLimits/read` snapshot or refetch. Nullable account metadata
in a rolling notification does not clear a previously observed value.

## Design decisions

- Extend the existing rate-limit snapshot instead of introducing a
separate request or wire-level update protocol.
- Keep the Codex projection narrow: `/status` needs the effective limit,
current usage, remaining percentage, and reset timestamp.
- Render the monthly row through the existing progress-bar renderer,
with one optional detail line for `8,000 of 25,000 credits used`.
- Keep the backend response optional so existing accounts and older
usage states preserve their current behavior.
- Preserve cached monthly metadata when sparse rolling notifications
omit it. Live account-usage reads remain authoritative and can clear a
removed limit.

## Visual evidence

```text
 Monthly credit limit:   [██████████████░░░░░░] 68% left (resets 07:08 on 7 May)
                         8,000 of 25,000 credits used
```

Snapshot:
`codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap`

## Testing

Tests: generated app-server schema verification, protocol tests,
backend-client tests, app-server integration coverage, TUI snapshot
coverage, formatting, and workspace lint cleanup.
2026-06-01 21:25:42 -07:00

199 lines
6.2 KiB
Rust

use super::*;
use crate::session::tests::make_session_configuration_for_tests;
use crate::state::AutoCompactWindowSnapshot;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use pretty_assertions::assert_eq;
#[tokio::test]
// Verifies connector merging deduplicates repeated IDs.
async fn merge_connector_selection_deduplicates_entries() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
let merged = state.merge_connector_selection([
"calendar".to_string(),
"calendar".to_string(),
"drive".to_string(),
]);
assert_eq!(
merged,
HashSet::from(["calendar".to_string(), "drive".to_string()])
);
}
#[tokio::test]
// Verifies clearing connector selection removes all saved IDs.
async fn clear_connector_selection_removes_entries() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_connector_selection(["calendar".to_string()]);
state.clear_connector_selection();
assert_eq!(state.get_connector_selection(), HashSet::new());
}
#[tokio::test]
async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.set_rate_limits(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 12.0,
window_minutes: Some(60),
resets_at: Some(100),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
assert_eq!(
state
.latest_rate_limits
.as_ref()
.and_then(|v| v.limit_id.clone()),
Some("codex".to_string())
);
}
#[tokio::test]
async fn replace_history_clears_auto_compact_window_prefill_without_advancing() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.start_next_auto_compact_window();
state.set_auto_compact_window_estimated_prefill(/*tokens*/ 100);
state.replace_history(Vec::new(), /*reference_context_item*/ None);
assert_eq!(
state.auto_compact_window_snapshot(),
AutoCompactWindowSnapshot {
ordinal: 2,
prefill_input_tokens: None,
}
);
}
#[tokio::test]
async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_bucket() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 20.0,
window_minutes: Some(60),
resets_at: Some(200),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
state.set_rate_limits(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(60),
resets_at: Some(300),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
assert_eq!(
state
.latest_rate_limits
.as_ref()
.and_then(|v| v.limit_id.clone()),
Some("codex".to_string())
);
}
#[tokio::test]
async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: Some(RateLimitWindow {
used_percent: 10.0,
window_minutes: Some(60),
resets_at: Some(100),
}),
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("50".to_string()),
}),
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 300,
}),
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
});
state.set_rate_limits(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(120),
resets_at: Some(200),
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
});
assert_eq!(
state.latest_rate_limits,
Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30.0,
window_minutes: Some(120),
resets_at: Some(200),
}),
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("50".to_string()),
}),
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 300,
}),
plan_type: Some(codex_protocol::account::PlanType::Plus),
rate_limit_reached_type: None,
})
);
}