diff --git a/README.md b/README.md index f43ad986..aec9c002 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - [Background terminal tasks](docs/async-tasks.md) in AIR, with task status and targeted stop support after capability negotiation. - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md). - A per-turn [agent file-change report](docs/agent-file-change-report.md) after capability negotiation. +- The account's usage windows and their reset times, pushed as they change through the [rate limits extension](docs/rate-limits-extension.md). - Client-provided MCP servers over command-based stdio config and HTTP transport. - Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. diff --git a/docs/rate-limits-extension.md b/docs/rate-limits-extension.md new file mode 100644 index 00000000..7c289bb7 --- /dev/null +++ b/docs/rate-limits-extension.md @@ -0,0 +1,116 @@ +# Rate limits extension + +Status: Experimental + +The agent pushes the usage windows of the account this connection bills to, so +a client can show how much of a window is spent and, when a turn is refused for +a spent window, knows when that window clears. It is the account-level +companion of the `authStatus` push and has the same shape of contract: push +only, connection-scoped, nothing for the client to request. + +## Why + +Codex refuses a turn on a spent window with `codexErrorInfo: +"usageLimitExceeded"`, and that error carries no reset time. The reset is +reported on the app-server's `account/rateLimits/updated` notification, which +the agent received but did not forward: it only fed the `/status` command's +text. A client that wants to park the session and resume it when the window +clears — instead of guessing or retrying blind — needs the structured value. +The two arrive as separate messages; a client should treat the latest pushed +windows as the reset to schedule against and not depend on either arriving +first. + +## Capability + +The `initialize` response advertises the push under +`agentCapabilities._meta.rateLimits` as an empty object. Its presence means +"this agent pushes `_account/rate_limits_update`". It never carries a payload +and gates nothing the client sends. + +```json +{ + "agentCapabilities": { + "_meta": { + "rateLimits": {} + } + } +} +``` + +## Notification + +Method: `_account/rate_limits_update` + +```json +{ + "rateLimits": { + "limitId": "codex", + "limitName": "Codex", + "normalModelSlug": null, + "primary": { "usedPercent": 100, "windowDurationMins": 300, "resetsAt": 1789511479 }, + "secondary": { "usedPercent": 41, "windowDurationMins": 10080, "resetsAt": 1789841013 }, + "rateLimitReachedType": "rate_limit_reached", + "planType": "plus" + } +} +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `limitId` | string | Stable id of the limit; `"codex"` for the account's ordinary usage limit. | +| `limitName` | string \| null | Human-readable name, when the backend names it. | +| `normalModelSlug` | string \| null | For a model-specific limit, the model whose quota these windows are; `null` for the account's ordinary limit. | +| `primary`, `secondary` | window \| null | The rolling windows; `null` when the backend did not report one. | +| `window.usedPercent` | number | Share of the window already spent, 0–100. | +| `window.windowDurationMins` | integer \| null | Window length in minutes: `300` for the 5-hour window, `10080` for the weekly one. | +| `window.resetsAt` | integer \| null | Unix time in **seconds** at which the window clears. | +| `rateLimitReachedType` | string \| null | The reached state as reported on the latest update: non-null when the backend reported that it is refusing turns on this limit, and why (`rate_limit_reached`, the workspace credit and usage variants); `null` when the latest update reported none. Clients tolerate values they do not recognise. | +| `planType` | string \| null | Vendor plan string, not normalised. | + +Field names and units follow codex app-server's `RateLimitSnapshot`. + +## When it is pushed + +On every app-server `account/rateLimits/updated` whose merged payload for that +`limitId` differs from the last one pushed for it. That notification is sparse — +the app-server asks clients to merge available values into the most recent +snapshot — so the agent merges first, against a connection-level baseline kept +per `limitId`, and pushes the complete picture; a client never needs +`account/rateLimits/read`. `limitName`, `planType` and the other account +metadata carry forward through the merge; the windows are taken as reported +(a `null` window means the update did not report one). Duplicates are +suppressed per `limitId`, including the copies produced when an account-level +notification reaches several open sessions. + +A push replaces the client's state **for that `limitId`**. An account can have +more than one limit (each is pushed and deduplicated separately), so a client +keeps a map keyed by `limitId` rather than a single value; `normalModelSlug` +says which model a limit belongs to. + +The agent observes the app-server notification once, at the connection, +before it reaches the per-session handlers, so the number of open sessions +neither duplicates nor reorders pushes. + +## Account changes + +The windows belong to the signed-in account. On a logout, or an `authStatus` +push that reports a different account, the agent drops its baseline and +duplicate filter, so the first update for the new account is pushed even when +its values equal the previous account's. A client should drop the windows it +holds when `authStatus` changes and wait for the next push. + +## Which reset to schedule against + +`primary.resetsAt` / `secondary.resetsAt` are the rolling usage windows and are +the reset for `rateLimitReachedType: "rate_limit_reached"`. The workspace +variants (`workspace_owner_usage_limit_reached`, +`workspace_member_usage_limit_reached`, and the credit-depletion variants) are +spend controls, not usage windows: this payload carries **no** reset for them, +and a client must not derive one from the rolling windows. Their state remains +readable through the `/status` command. + +## What is not forwarded + +Credit balances, spend controls and the individual spend limit are billing +state rather than usage windows and are left out. They remain readable through +the `/status` command. diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index b450c8bd..5546a5d6 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -27,6 +27,18 @@ export { type AuthStatusUpdateNotification, } from "./AuthStatusMeta"; +export { + RATE_LIMITS_META_KEY, + RATE_LIMITS_UPDATE_METHOD, + rateLimitsCapability, + sameRateLimits, + toRateLimits, + type RateLimits, + type RateLimitsCapability, + type RateLimitsUpdateNotification, + type RateLimitsWindow, +} from "./RateLimitsMeta"; + export { GOAL_CONTROL_ACTIONS, GOAL_CONTROL_METHOD, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 694c44df..d1bab8cb 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -31,8 +31,7 @@ import type { Thread, ThreadGoal, ThreadItem, - UserInput -} from "./app-server/v2"; + UserInput, AccountRateLimitsUpdatedNotification, RateLimitSnapshot} from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; @@ -126,6 +125,20 @@ import { gatewayStatus, sameAuthStatus, } from "./AuthStatusMeta"; +import { + RATE_LIMITS_META_KEY, + RATE_LIMITS_UPDATE_METHOD, + rateLimitsCapability, + sameRateLimits, + toRateLimits, + type RateLimits, +} from "./RateLimitsMeta"; +import {mergeRateLimitSnapshot} from "./RateLimitsMap"; + +/** Whether two auth statuses describe the same signed-in identity. */ +function sameAccount(previous: AuthStatus, next: AuthStatus): boolean { + return previous.kind === next.kind && previous.account?.email === next.account?.email; +} import {randomUUID} from "node:crypto"; import {TitleGenerator} from "./TitleGenerator"; import {once} from "node:events"; @@ -272,6 +285,14 @@ export class CodexAcpServer { private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; + /** Connection-level merge baseline for the sparse app-server + * `account/rateLimits/updated`, per `limitId`. Per-session state cannot be + * the baseline: it is reset on every session create and the account + * notification fans out to every session's handler. */ + private readonly rateLimitSnapshots = new Map(); + /** Last `_account/rate_limits_update` payload pushed per `limitId`; the + * duplicate filter. */ + private readonly pushedRateLimits = new Map(); private readonly sessions: Map; private readonly pendingMcpStartupSessions: Map; @@ -308,6 +329,7 @@ export class CodexAcpServer { this.permissionLifecycleContexts = new WeakMap(); this.connection = connection; this.codexAcpClient = codexAcpClient; + this.observeAccountNotifications(codexAcpClient); this.defaultAuthRequest = defaultAuthRequest ?? null; this.codexProcessState = codexProcessState ?? null; this.captureStderr(); @@ -379,6 +401,9 @@ export class CodexAcpServer { // Presence means "this agent pushes `_auth/status_update`". It // never carries a payload, and the client never asks for one. [AUTH_STATUS_META_KEY]: authStatusCapability(), + // Presence means "this agent pushes `_account/rate_limits_update`" + // (RateLimitsMeta.ts). Same contract shape as `authStatus`. + [RATE_LIMITS_META_KEY]: rateLimitsCapability(), }, }, authMethods: getCodexAuthMethods(_params.clientCapabilities), @@ -1002,6 +1027,7 @@ export class CodexAcpServer { async logout(_params: acp.LogoutRequest): Promise { logger.log("Logout request received"); await this.runWithProcessCheck(() => this.codexAcpClient.logout()); + this.forgetRateLimits(); await this.refreshAuthState(null); logger.log("Logout request completed"); } @@ -1050,6 +1076,7 @@ export class CodexAcpServer { } await replacement.initialize(this.initializeRequest); this.codexAcpClient = replacement; + this.observeAccountNotifications(replacement); this.availableCommands = this.createAvailableCommands(replacement); const resumeErrors: unknown[] = []; @@ -1301,6 +1328,9 @@ export class CodexAcpServer { if (sameAuthStatus(this.currentAuthStatus, next)) { return; } + if (this.currentAuthStatus !== null && !sameAccount(this.currentAuthStatus, next)) { + this.forgetRateLimits(); + } this.currentAuthStatus = next; try { await this.connection.notify(AUTH_STATUS_UPDATE_METHOD, {authStatus: next}); @@ -1309,6 +1339,74 @@ export class CodexAcpServer { } } + /** + * Subscribes to account-level app-server notifications at the connection, + * where each arrives exactly once and in receive order. A per-session + * handler would see the same notification once per open session, from + * queues that can reorder the copies, so a stale snapshot could overwrite a + * newer one. Re-run for a replacement client. + */ + private observeAccountNotifications(client: CodexAcpClient): void { + client.appServerClient.onAccountNotification((notification) => { + if (notification.method === "account/rateLimits/updated") { + this.handleRateLimitsUpdated(notification.params); + } + }); + } + + /** + * Handles the app-server `account/rateLimits/updated` push. + * + * The notification is sparse: the app-server asks clients to merge available + * values into the most recent snapshot. That merge happens HERE, against a + * connection-level baseline per `limitId`, because per-session state is reset + * on every session create and would make a freshly created session report + * `planType: null` for the same account the previous session knew. The + * windows themselves are taken as reported (a `null` window is "not + * reported", not "unchanged"); `limitName` and the account metadata carry + * forward through {@link mergeRateLimitSnapshot}. + */ + handleRateLimitsUpdated(notification: AccountRateLimitsUpdatedNotification): void { + const raw = notification.rateLimits; + const limitId = raw.limitId ?? "codex"; + const previous = this.rateLimitSnapshots.get(limitId); + const merged: RateLimitSnapshot = previous + ? mergeRateLimitSnapshot(previous, raw) + : {...raw, limitId}; + merged.limitName = merged.limitName ?? previous?.limitName ?? null; + this.rateLimitSnapshots.set(limitId, merged); + void this.setRateLimits(toRateLimits(limitId, merged)); + } + + /** + * Pushes `_account/rate_limits_update` for one limit when its payload changed. + * A push replaces the client's state for that `limitId` only; other limits + * on the account are pushed separately, so a repeat of one limit's windows + * (the fan-out, or a sparse update that added nothing) never goes out. + */ + /** + * The windows belong to the account that is signed in. When that changes + * (logout, or a login reported for a different account) the baseline and + * the duplicate filter are dropped, so the next update for the new account + * is pushed even when its values happen to equal the old account's. + */ + private forgetRateLimits(): void { + this.rateLimitSnapshots.clear(); + this.pushedRateLimits.clear(); + } + + private async setRateLimits(next: RateLimits): Promise { + if (sameRateLimits(this.pushedRateLimits.get(next.limitId) ?? null, next)) { + return; + } + this.pushedRateLimits.set(next.limitId, next); + try { + await this.connection.notify(RATE_LIMITS_UPDATE_METHOD, {rateLimits: next}); + } catch (error) { + logger.log("Failed to send rate limits update", {error: String(error)}); + } + } + async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 03571bea..43224dec 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -822,6 +822,18 @@ export class CodexAppServerClient { this.codexEventHandlers.push(callback); } + /** + * Registers a listener for account-level (thread-less) notifications. Each + * such notification reaches every listener exactly once, in receive order, + * before the per-session fan-out below; a subscriber that needs the + * account's state as one ordered stream uses this rather than a session + * handler, whose asynchronous queue can reorder copies across sessions. + */ + onAccountNotification(callback: (event: ServerNotification) => void) { + this.accountNotificationListeners.push(callback); + } + + private accountNotificationListeners: Array<(event: ServerNotification) => void> = []; private notificationHandlers = new Map void>(); private notify(notification: ServerNotification) { const threadId = extractThreadId(notification); @@ -832,6 +844,9 @@ export class CodexAppServerClient { } return; } + for (const listener of this.accountNotificationListeners) { + listener(notification); + } for (const notificationHandler of this.notificationHandlers.values()) { notificationHandler(notification); } diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index b6b2275f..8392ec66 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -1415,6 +1415,10 @@ export class CodexEventHandler { limitName: snapshot.limitName ?? existingEntry?.limitName ?? limitId, snapshot, }); + // Per-session merge for `/status` only. The `_account/rate_limits_update` + // push is fed from the connection-level listener in CodexAcpServer, not + // from here: this baseline is reset on every session create, and the + // account notification fans out to every session's handler. } private handleFuzzyFileSearchSessionUpdated( diff --git a/src/RateLimitsMeta.ts b/src/RateLimitsMeta.ts new file mode 100644 index 00000000..94288cf5 --- /dev/null +++ b/src/RateLimitsMeta.ts @@ -0,0 +1,135 @@ +import type {PlanType} from "./app-server/PlanType"; +import type {RateLimitReachedType, RateLimitSnapshot} from "./app-server/v2"; + +/** + * `rateLimits` — interim `_meta`-based ACP extension that pushes the usage + * windows of the account this connection bills to, so a client can show how + * much of the 5-hour and weekly window is spent and, when a turn is refused + * for a spent window, knows when that window clears. + * + * Push only, connection-scoped: the windows belong to the account, not to a + * session, exactly like `authStatus`. The carrier is the notification + * `_account/rate_limits_update` with `{rateLimits}`. The client sends nothing. + * The agent observes the app-server's account notification once, at the + * connection, before it fans out to the per-session handlers, so the push is + * ordered and never duplicated by the number of open sessions. + * + * The agent pushes on every app-server `account/rateLimits/updated` whose + * merged payload for that `limitId` differs from the last one pushed for it + * ({@link sameRateLimits}). That notification is sparse ("merge available + * values into the most recent snapshot"), so the agent merges first — against + * a connection-level baseline per `limitId`, never per-session state, which is + * reset on every session create while the account notification fans out to + * every session's handler — and pushes the complete picture; a client never + * needs `account/rateLimits/read`. A push replaces the client's state for that + * `limitId` only. + * + * The moment that matters most is a refused prompt: Codex reports the spent + * window on `account/rateLimits/updated` and fails the turn with + * `codexErrorInfo: "usageLimitExceeded"`, and that error carries no reset + * time. This push does — `primary.resetsAt` / `secondary.resetsAt` — which is + * what lets a client park the session and resume it when the window clears + * instead of guessing. The two are separate messages with no ordering promise. + * + * Field names and units follow codex app-server's `RateLimitSnapshot` so a + * reader of either doc reads the other. Spend-control and credit balances are + * deliberately not forwarded: they are billing state, not usage windows. + * + * The payload moves to first-class protocol fields if ACP standardises + * rate-limit reporting; this extension is retired then. + */ +export const RATE_LIMITS_UPDATE_METHOD = "_account/rate_limits_update"; +export const RATE_LIMITS_META_KEY = "rateLimits"; + +/** One rolling usage window. */ +export interface RateLimitsWindow { + /** Share of the window already spent, `0`–`100`. */ + usedPercent: number; + /** Window length in minutes (`300` for the 5-hour window, `10080` for the + * weekly one); `null` when the backend did not report it. */ + windowDurationMins: number | null; + /** Unix time in SECONDS at which the window clears; `null` when the + * backend did not report it. */ + resetsAt: number | null; +} + +export interface RateLimits { + /** Stable id of the limit these windows belong to (`"codex"` for the + * account's ordinary usage limit). */ + limitId: string; + /** Human-readable name of the limit, when the backend names it. */ + limitName: string | null; + /** For a model-specific limit, the model whose quota these windows are; + * `null` for the account's ordinary limit. This is how a client ties a + * refusal to the windows it should schedule against. */ + normalModelSlug: string | null; + primary: RateLimitsWindow | null; + secondary: RateLimitsWindow | null; + /** The reached state as reported on the latest update: non-null when the + * backend reported that it is refusing turns on this limit, and why; + * `null` when the latest update reported none. Clients tolerate values + * they do not recognise. */ + rateLimitReachedType: RateLimitReachedType | null; + /** Vendor plan string, not normalised. */ + planType: PlanType | null; +} + +/** Params of the `_account/rate_limits_update` notification. */ +export type RateLimitsUpdateNotification = { + rateLimits: RateLimits; +} + +/** + * Capability advertised in the `initialize` response under + * `agentCapabilities._meta.rateLimits`: an empty object whose presence means + * "this agent pushes its usage windows". It never carries a payload. + */ +export type RateLimitsCapability = {} + +export function rateLimitsCapability(): RateLimitsCapability { + return {}; +} + +/** The pushed subset of a merged app-server snapshot. */ +export function toRateLimits(limitId: string, snapshot: RateLimitSnapshot): RateLimits { + return { + limitId, + limitName: snapshot.limitName ?? null, + normalModelSlug: snapshot.normalModelSlug ?? null, + primary: toWindow(snapshot.primary), + secondary: toWindow(snapshot.secondary), + rateLimitReachedType: snapshot.rateLimitReachedType ?? null, + planType: snapshot.planType ?? null, + }; +} + +function toWindow(window: RateLimitSnapshot["primary"]): RateLimitsWindow | null { + if (window === null || window === undefined) { + return null; + } + return { + usedPercent: window.usedPercent, + windowDurationMins: window.windowDurationMins ?? null, + resetsAt: window.resetsAt ?? null, + }; +} + +export function sameRateLimits(a: RateLimits | null, b: RateLimits): boolean { + return a !== null + && a.limitId === b.limitId + && a.limitName === b.limitName + && a.normalModelSlug === b.normalModelSlug + && a.rateLimitReachedType === b.rateLimitReachedType + && a.planType === b.planType + && sameWindow(a.primary, b.primary) + && sameWindow(a.secondary, b.secondary); +} + +function sameWindow(a: RateLimitsWindow | null, b: RateLimitsWindow | null): boolean { + if (a === null || b === null) { + return a === b; + } + return a.usedPercent === b.usedPercent + && a.windowDurationMins === b.windowDurationMins + && a.resetsAt === b.resetsAt; +} diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e6bdb8bb..ae4f8f3a 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -63,6 +63,7 @@ describe('CodexACPAgent - initialize', () => { }, _meta: { authStatus: {}, + rateLimits: {}, }, }, authMethods: getCodexAuthMethods(), diff --git a/src/__tests__/CodexACPAgent/rate-limits-events.test.ts b/src/__tests__/CodexACPAgent/rate-limits-events.test.ts new file mode 100644 index 00000000..6bad936e --- /dev/null +++ b/src/__tests__/CodexACPAgent/rate-limits-events.test.ts @@ -0,0 +1,285 @@ +import {describe, expect, it, vi} from "vitest"; +import {PROTOCOL_VERSION} from "@agentclientprotocol/sdk"; +import { + createCodexMockTestFixture, + createTestSessionState, + type CodexMockTestFixture, + type MethodCallEvent, +} from "../acp-test-utils"; +import { + RATE_LIMITS_META_KEY, + RATE_LIMITS_UPDATE_METHOD, + type RateLimits, +} from "../../RateLimitsMeta"; +import {CodexEventHandler} from "../../CodexEventHandler"; +import type {AcpClientConnection} from "../../ACPSessionConnection"; +import type {AccountRateLimitsUpdatedNotification, RateLimitSnapshot} from "../../app-server/v2"; + +const FIVE_HOURS_RESET = 1789511479; +const WEEK_RESET = 1789841013; + +/** A complete app-server snapshot with headroom in both windows. */ +function snapshot(overrides: Partial = {}): RateLimitSnapshot { + return { + limitId: "codex", + limitName: "Codex", + normalModelSlug: null, + primary: {usedPercent: 41, windowDurationMins: 300, resetsAt: FIVE_HOURS_RESET}, + secondary: {usedPercent: 12, windowDurationMins: 10080, resetsAt: WEEK_RESET}, + credits: {hasCredits: true, unlimited: false, balance: "12.50"}, + individualLimit: {limit: "25000", used: "8000", remainingPercent: 72, resetsAt: WEEK_RESET}, + spendControlReached: false, + planType: "plus", + rateLimitReachedType: null, + ...overrides, + }; +} + +/** The rolling update that spends the 5-hour window: windows present, every + * piece of account metadata absent — the shape the sparse contract allows. */ +const SPENT_SPARSE: Partial = { + limitName: null, + primary: {usedPercent: 100, windowDurationMins: 300, resetsAt: FIVE_HOURS_RESET}, + credits: null, + individualLimit: null, + spendControlReached: null, + planType: null, + rateLimitReachedType: "rate_limit_reached", +}; + +function notification(s: RateLimitSnapshot): AccountRateLimitsUpdatedNotification { + return {rateLimits: s}; +} + +/** What the connection was told, in order. */ +function pushes(fixture: CodexMockTestFixture): RateLimits[] { + return fixture.getAcpConnectionEvents([]) + .filter((event: MethodCallEvent) => event.method === "notify" && event.args[0] === RATE_LIMITS_UPDATE_METHOD) + .map((event: MethodCallEvent) => (event.args[1] as {rateLimits: RateLimits}).rateLimits); +} + +async function awaitPushes(fixture: CodexMockTestFixture, count: number): Promise { + await vi.waitFor(() => expect(pushes(fixture)).toHaveLength(count)); + return pushes(fixture); +} + +/** Lets every already-scheduled callback run, so "nothing more was pushed" is a + * verdict and not a race. */ +async function drainScheduledWork(): Promise { + for (let round = 0; round < 5; round += 1) { + await new Promise(resolve => setImmediate(resolve)); + } +} + +const HEADROOM: RateLimits = { + limitId: "codex", + limitName: "Codex", + normalModelSlug: null, + primary: {usedPercent: 41, windowDurationMins: 300, resetsAt: FIVE_HOURS_RESET}, + secondary: {usedPercent: 12, windowDurationMins: 10080, resetsAt: WEEK_RESET}, + rateLimitReachedType: null, + planType: "plus", +}; + +const SPENT: RateLimits = { + ...HEADROOM, + primary: {usedPercent: 100, windowDurationMins: 300, resetsAt: FIVE_HOURS_RESET}, + rateLimitReachedType: "rate_limit_reached", +}; + +describe("rateLimits extension", () => { + describe("capability marker", () => { + it("is advertised in the initialize response, without a payload", async () => { + const fixture = createCodexMockTestFixture(); + + const response = await fixture.getCodexAcpAgent().initialize({protocolVersion: PROTOCOL_VERSION}); + + expect(response.agentCapabilities?._meta?.[RATE_LIMITS_META_KEY]).toEqual({}); + expect(response._meta?.[RATE_LIMITS_META_KEY]).toBeUndefined(); + }); + }); + + describe("_account/rate_limits_update notification", () => { + it("pushes the usage windows and leaves the billing fields behind", async () => { + const fixture = createCodexMockTestFixture(); + + fixture.getCodexAcpAgent().handleRateLimitsUpdated(notification(snapshot())); + + // Credits, the individual spend limit and spend control are not usage + // windows; the wire payload is exactly the documented subset. + expect(await awaitPushes(fixture, 1)).toEqual([HEADROOM]); + }); + + it("is not sent again while the windows are unchanged", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + + agent.handleRateLimitsUpdated(notification(snapshot())); + await awaitPushes(fixture, 1); + agent.handleRateLimitsUpdated(notification(snapshot())); + // A billing-only change is not a usage-window change. + agent.handleRateLimitsUpdated(notification(snapshot({credits: {hasCredits: false, unlimited: false, balance: "0"}}))); + await drainScheduledWork(); + + expect(pushes(fixture)).toHaveLength(1); + }); + + it("merges a sparse update against the connection's baseline, keeping the name and plan", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + + agent.handleRateLimitsUpdated(notification(snapshot())); + await awaitPushes(fixture, 1); + agent.handleRateLimitsUpdated(notification(snapshot(SPENT_SPARSE))); + + // `limitName` and `planType` were absent from the update and must not + // drop to null on the wire; the windows are the update's. + const [, spent] = await awaitPushes(fixture, 2); + expect(spent).toEqual(SPENT); + }); + + it("does not push a sparse update that adds nothing", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + + agent.handleRateLimitsUpdated(notification(snapshot())); + await awaitPushes(fixture, 1); + // Same windows, every metadata field absent: after the merge this is + // the previous payload again. + agent.handleRateLimitsUpdated(notification(snapshot({ + limitName: null, + credits: null, + individualLimit: null, + spendControlReached: null, + planType: null, + }))); + await drainScheduledWork(); + + expect(pushes(fixture)).toEqual([HEADROOM]); + }); + + it("reports a window the backend left unspecified as null, not as a guess", async () => { + const fixture = createCodexMockTestFixture(); + + fixture.getCodexAcpAgent().handleRateLimitsUpdated(notification(snapshot({ + limitName: null, + primary: {usedPercent: 3, windowDurationMins: null, resetsAt: null}, + secondary: null, + planType: null, + }))); + + expect(await awaitPushes(fixture, 1)).toEqual([{ + limitId: "codex", + limitName: null, + normalModelSlug: null, + primary: {usedPercent: 3, windowDurationMins: null, resetsAt: null}, + secondary: null, + rateLimitReachedType: null, + planType: null, + }]); + }); + + it("tracks each limitId on its own", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + + agent.handleRateLimitsUpdated(notification(snapshot())); + agent.handleRateLimitsUpdated(notification(snapshot({ + limitId: "fast", + limitName: "Fast", + primary: {usedPercent: 80, windowDurationMins: 1440, resetsAt: FIVE_HOURS_RESET}, + secondary: null, + }))); + await awaitPushes(fixture, 2); + // A repeat of either limit is a duplicate of THAT limit, not a change + // relative to the other one. + agent.handleRateLimitsUpdated(notification(snapshot())); + await drainScheduledWork(); + + const all = pushes(fixture); + expect(all).toHaveLength(2); + expect(all.map((limit) => limit.limitId)).toEqual(["codex", "fast"]); + }); + }); + + describe("account/rateLimits/updated through the app-server connection", () => { + const method = "account/rateLimits/updated" as const; + + it("carries the complete picture through a sparse update", async () => { + const fixture = createCodexMockTestFixture(); + + fixture.sendServerNotification({method, params: notification(snapshot())}); + await awaitPushes(fixture, 1); + fixture.sendServerNotification({method, params: notification(snapshot(SPENT_SPARSE))}); + + expect(await awaitPushes(fixture, 2)).toEqual([HEADROOM, SPENT]); + }); + + it("pushes once however many sessions the notification fans out to", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + // Two open sessions: codex delivers a thread-less notification to + // each of their handlers, but the push is fed at the connection. + appServer.onServerNotification("session-a", () => {}); + appServer.onServerNotification("session-b", () => {}); + + fixture.sendServerNotification({method, params: notification(snapshot())}); + fixture.sendServerNotification({method, params: notification(snapshot())}); + await drainScheduledWork(); + + expect(pushes(fixture)).toEqual([HEADROOM]); + }); + + it("names the model a model-specific limit belongs to", async () => { + const fixture = createCodexMockTestFixture(); + + fixture.sendServerNotification({method, params: notification(snapshot({ + limitId: "fast", + limitName: "Fast", + normalModelSlug: "gpt-5.6-sol", + secondary: null, + }))}); + + const [fast] = await awaitPushes(fixture, 1); + expect(fast!.limitId).toBe("fast"); + expect(fast!.normalModelSlug).toBe("gpt-5.6-sol"); + }); + + it("forgets the windows on logout so the next account's first update is pushed", async () => { + const fixture = createCodexMockTestFixture(); + const client = fixture.getCodexAcpClient(); + vi.spyOn(client, "logout").mockResolvedValue(undefined); + vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: true}); + + fixture.sendServerNotification({method, params: notification(snapshot())}); + await awaitPushes(fixture, 1); + await fixture.getCodexAcpAgent().logout({}); + // The new account happens to report the same values: still news. + fixture.sendServerNotification({method, params: notification(snapshot())}); + + expect(await awaitPushes(fixture, 2)).toEqual([HEADROOM, HEADROOM]); + }); + + it("still merges per session for /status", async () => { + const fixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(); + const handler = new CodexEventHandler( + {notify: vi.fn(async () => {}), request: vi.fn()} as unknown as AcpClientConnection, + sessionState, + false, + false, + "epoch", + ); + + await handler.handleNotification({method, params: notification(snapshot())}); + await handler.handleNotification({method, params: notification(snapshot(SPENT_SPARSE))}); + + const entry = sessionState.rateLimits?.get("codex"); + expect(entry?.limitName).toBe("Codex"); + expect(entry?.snapshot.planType).toBe("plus"); + expect(entry?.snapshot.primary?.usedPercent).toBe(100); + // And nothing was pushed: the per-session merge is not the feed. + expect(pushes(fixture)).toEqual([]); + }); + }); +});