diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 1684f787a..0ff4e9b8b 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -41,11 +41,29 @@ const ClaudeUsageWindowSchema = z.object({ resets_at: z.string().nullish(), }); +// Model- and surface-scoped weekly limits live in a `limits[]` array rather than a +// top-level `seven_day_` key. Entries are validated one at a time (see +// scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down +// the windows that already parsed from the top-level keys. +const ClaudeScopeLabelSchema = z + .object({ id: z.string().nullish(), display_name: z.string().nullish() }) + .nullish(); + +const ClaudeLimitSchema = z.object({ + kind: z.string(), + percent: ApiNumberSchema.nullish(), + resets_at: z.string().nullish(), + scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(), +}); + const ClaudeUsageResponseSchema = z.object({ five_hour: ClaudeUsageWindowSchema.nullish(), seven_day: ClaudeUsageWindowSchema.nullish(), seven_day_opus: ClaudeUsageWindowSchema.nullish(), seven_day_omelette: ClaudeUsageWindowSchema.nullish(), + // Deliberately permissive: an additive section must never regress the top-level + // windows, so shape validation happens per entry rather than here. + limits: z.array(z.unknown()).nullish(), extra_usage: z .object({ is_enabled: z.boolean().optional(), @@ -61,6 +79,9 @@ const ClaudeTokenRefreshSchema = z.object({ type ClaudeCredentials = z.infer; type ClaudeUsageResponse = z.infer; type ClaudeTokenRefresh = z.infer; +type ClaudeLimit = z.infer; + +const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; @@ -85,6 +106,197 @@ function buildClaudePlan( return tier ? `${label} ${tier}` : label; } +/** + * A weekly limit scoped to one model or one surface, normalized away from whichever + * shape of the response described it. + * + * The API describes the same limit two ways during the migration: a legacy top-level + * `seven_day_` key, and an entry in `limits[]`. Everything downstream works on + * this one representation so the two shapes are reconciled exactly once, in + * `reconcileScopedLimits`, rather than at each place a window is built. + */ +interface ScopedLimit { + dimension: "model" | "surface"; + /** The API's own identifier. Null on every response observed so far. */ + id: string | null; + /** Display name, or the id when the API sends no display name. */ + name: string; + usedPct: number | null; + resetsAt: string | null; +} + +// Windows that describe no particular model or surface. +const UNSCOPED_WINDOWS: ReadonlyArray<{ + field: "five_hour" | "seven_day"; + id: string; + label: string; +}> = [ + { field: "five_hour", id: "five_hour", label: "Session" }, + { field: "seven_day", id: "weekly", label: "Weekly" }, +]; + +// Scoped windows from before `limits[]` existed. Declaring the dimension here is what +// stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model* +// window: these keys are model-scoped by definition. +const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{ + field: "seven_day_opus" | "seven_day_omelette"; + name: string; +}> = [ + { field: "seven_day_opus", name: "Opus" }, + { field: "seven_day_omelette", name: "Omelette" }, +]; + +/** Fold a name down to the characters an id is allowed to carry. */ +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Whether two descriptions denote the same limit. This is the single definition of + * identity for scoped limits; nothing else may compare them, and in particular nothing + * may compare display labels, which are presentation rather than identity. + * + * - Different dimensions are never the same limit, so a surface and a model sharing a + * name stay apart. + * - When both sides carry the API's own id, that id decides, so `fable-pro` and + * `fable_pro` stay apart. + * - Otherwise fall back to the normalized name, which is the only link available between + * a legacy key (never has an id) and its `limits[]` counterpart. + */ +function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean { + if (a.dimension !== b.dimension) return false; + if (a.id && b.id) return a.id === b.id; + return normalizeName(a.name) === normalizeName(b.name); +} + +/** + * Merge the legacy and `limits[]` descriptions into one limit per identity. + * + * A `limits[]` entry wins on identity because that is the representation the API is + * migrating towards, so a limit keeps the same window id whichever shape carried it. + * Its values are nullable though, so each field falls back to the legacy twin instead of + * discarding a number the response did contain. + */ +function reconcileScopedLimits( + legacy: ScopedLimit[], + fromLimitsArray: ScopedLimit[], +): ScopedLimit[] { + const reconciled = [...legacy]; + for (const limit of fromLimitsArray) { + const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit)); + if (index === -1) { + reconciled.push(limit); + continue; + } + const twin = reconciled[index]; + reconciled[index] = { + ...limit, + usedPct: limit.usedPct ?? twin?.usedPct ?? null, + resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null, + }; + } + return reconciled; +} + +function scopedLimitFromLegacy( + spec: (typeof LEGACY_SCOPED_WINDOWS)[number], + window: z.infer, +): ScopedLimit { + return { + dimension: "model", + id: null, + name: spec.name, + usedPct: window.utilization, + resetsAt: window.resets_at ?? null, + }; +} + +/** The scope of a `limits[]` entry, or null when it names nothing renderable. */ +function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null { + for (const dimension of ["model", "surface"] as const) { + const entry = limit.scope?.[dimension]; + const id = entry?.id?.trim() || null; + const name = entry?.display_name?.trim() || id; + if (name) { + return { + dimension, + id, + name, + usedPct: limit.percent ?? null, + resetsAt: limit.resets_at ?? null, + }; + } + } + return null; +} + +// The client uses window ids as React keys, so they must be stable across refreshes and +// unique within a response. An API-supplied id is already an identifier and is used +// verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`); +// only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and +// `fable_pro` into one window. +function scopedWindowId(limit: ScopedLimit): string { + return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`; +} + +// Backstop for the one residual case identity cannot rule out: an entry whose verbatim id +// equals another entry's normalized name. Suffix rather than drop, because a missing bar +// is the bug this change exists to fix. +function uniqueWindowId(candidate: string, taken: Set): string { + if (!taken.has(candidate)) return candidate; + for (let suffix = 2; ; suffix += 1) { + const next = `${candidate}_${suffix}`; + if (!taken.has(next)) return next; + } +} + +function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] { + const limits: ScopedLimit[] = []; + for (const spec of LEGACY_SCOPED_WINDOWS) { + const window = resp[spec.field]; + if (window) limits.push(scopedLimitFromLegacy(spec, window)); + } + return limits; +} + +function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] { + const windows: ProviderUsageWindow[] = []; + for (const spec of UNSCOPED_WINDOWS) { + const window = resp[spec.field]; + if (!window) continue; + windows.push( + windowFromUsedPct({ + id: spec.id, + label: spec.label, + utilizationPct: window.utilization, + resetsAt: window.resets_at ?? null, + tone: toneFromUsedPct(window.utilization), + }), + ); + } + return windows; +} + +function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] { + const taken = new Set(); + return limits.map((limit) => { + const id = uniqueWindowId(scopedWindowId(limit), taken); + taken.add(id); + // Emitted even at 0% and inactive: a zero bar answers "how much of this model have I + // used", and the bar must not come and go between refreshes. + return windowFromUsedPct({ + id, + label: `Weekly \u00b7 ${limit.name}`, + utilizationPct: limit.usedPct, + resetsAt: limit.resetsAt, + tone: toneFromUsedPct(limit.usedPct), + }); + }); +} + async function readClaudeKeychainCredentials(): Promise { try { const { stdout } = await execFileAsync( @@ -104,12 +316,14 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { readonly providerId = "claude"; readonly displayName = "Claude"; + private readonly logger: Logger; private readonly claudeHome: string; private readonly readKeychainCredentials: () => Promise; private readonly platform: typeof process.platform; private readonly fetchApi: ProviderApiFetch; constructor(options: ClaudeQuotaProviderOptions) { + this.logger = options.logger.child({ module: "claude-quota-provider" }); this.claudeHome = options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; @@ -149,50 +363,17 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { } } - const windows: ProviderUsageWindow[] = []; - if (resp.five_hour) { - windows.push( - windowFromUsedPct({ - id: "five_hour", - label: "Session", - utilizationPct: resp.five_hour.utilization, - resetsAt: resp.five_hour.resets_at ?? null, - tone: toneFromUsedPct(resp.five_hour.utilization), - }), - ); - } - if (resp.seven_day) { - windows.push( - windowFromUsedPct({ - id: "weekly", - label: "Weekly", - utilizationPct: resp.seven_day.utilization, - resetsAt: resp.seven_day.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day.utilization), - }), - ); - } - if (resp.seven_day_opus) { - windows.push( - windowFromUsedPct({ - id: "weekly_opus", - label: "Weekly · Opus", - utilizationPct: resp.seven_day_opus.utilization, - resetsAt: resp.seven_day_opus.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day_opus.utilization), - }), - ); - } - if (resp.seven_day_omelette) { - windows.push( - windowFromUsedPct({ - id: "weekly_omelette", - label: "Weekly · Omelette", - utilizationPct: resp.seven_day_omelette.utilization, - resetsAt: resp.seven_day_omelette.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day_omelette.utilization), - }), - ); + const scoped = reconcileScopedLimits( + legacyScopedLimits(resp), + this.scopedLimitsFromResponse(resp.limits), + ); + const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; + + if (windows.length === 0) { + // The response parsed but described nothing. That silence is how the previous + // shape change went unnoticed, so make it greppable. `warn` and not `debug` + // because file logging defaults to `info`. + this.logger.warn("Claude usage response parsed but produced no windows"); } const details: ProviderUsageDetail[] = []; @@ -217,6 +398,34 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { }; } + /** + * Scoped limits carried by `limits[]`. + * + * Entries are validated one at a time so a single malformed or newly-shaped entry + * cannot fail the whole response and take the windows that already parsed with it. + */ + private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { + if (!limits) return []; + + const parsed: ScopedLimit[] = []; + for (const entry of limits) { + const result = ClaudeLimitSchema.safeParse(entry); + if (!result.success) { + this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); + continue; + } + if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; + + const limit = scopedLimitFromEntry(result.data); + if (!limit) { + this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name"); + continue; + } + parsed.push(limit); + } + return parsed; + } + private async readCredentials(): Promise { const credPath = join(this.claudeHome, ".credentials.json"); diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 5dfc18408..7b1044cef 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -425,7 +425,7 @@ describe("real provider usage fetchers", () => { windows: expect.arrayContaining([ expect.objectContaining({ id: "five_hour", usedPct: 11 }), expect.objectContaining({ id: "weekly", usedPct: 1 }), - expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }), + expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }), ]), }); expect(fetchApi).toHaveBeenCalledWith( @@ -1025,3 +1025,353 @@ describe("usage bars escalate as they fill", () => { ); }); }); + +// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the +// top-level `seven_day_*` keys, which now return null on most accounts. +describe("ClaudeQuotaProvider scoped weekly limits", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function fableLimit(overrides: Record = {}) { + return { + kind: "weekly_scoped", + group: "weekly", + percent: 0, + severity: "normal", + resets_at: "2026-06-04T00:00:00Z", + is_active: false, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + ...overrides, + }; + } + + function claudeProvider(body: unknown) { + writeClaudeCredentials(claudeHome, "at_valid"); + const logger = createLogger() as unknown as { warn: ReturnType }; + const provider = new ClaudeQuotaProvider({ + logger: logger as never, + claudeHome, + claudeKeychainReader: async () => null, + fetch: mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ), + }); + return { provider, logger }; + } + + it("renders a scoped weekly limit as its own window", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), + ); + }); + + it("renders a scoped window that is at zero and inactive", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ percent: 0, is_active: false })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), + ); + }); + + it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, + { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, + fableLimit(), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + }); + + it("labels a surface-scoped limit from its surface name", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), + ); + }); + + it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { + const { provider, logger } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); + expect(logger.warn).toHaveBeenCalled(); + }); + + // Regression: an additive section must never take down data that already parsed. + it("keeps the top-level windows when a limits entry is malformed", async () => { + const { provider, logger } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [{ percent: "not-a-kind" }, fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("available"); + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("warns when a successful response describes no windows at all", async () => { + const { provider, logger } = claudeProvider({ limits: [] }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + "Claude usage response parsed but produced no windows", + ); + }); +}); +/** + * The reconciliation matrix. + * + * Four review rounds on PR #2303 each found a different hole in how the two + * representations of one scoped limit get combined, because each fix was tested against + * the case that was reported rather than the space of cases. This walks the space: + * every combination of which representation carries the limit, whether the `limits[]` + * entry supplies values, and whether the two descriptions denote the same limit at all. + */ +describe("ClaudeQuotaProvider scoped limit reconciliation", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + const RESETS = "2026-06-04T00:00:00Z"; + + function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { + return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; + } + + const model = (name: string | null, id: string | null = null) => ({ + model: { id, display_name: name }, + surface: null, + }); + const surface = (name: string | null, id: string | null = null) => ({ + model: null, + surface: { id, display_name: name }, + }); + + async function windowsFor(body: Record) { + writeClaudeCredentials(claudeHome, "at_valid"); + const usage = await new ClaudeQuotaProvider({ + logger: createLogger(), + claudeHome, + claudeKeychainReader: async () => null, + fetch: mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ), + }).fetchUsage(); + return usage.windows; + } + + it("which representation carries the limit: legacy only", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_omelette", + label: "Weekly · Omelette", + usedPct: 12, + }), + ]); + }); + + it("which representation carries the limit: limits[] only", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_fable", + label: "Weekly · Fable", + usedPct: 2, + }), + ]); + }); + + it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(model("Omelette"), 30)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), + ]); + }); + + it("which representation carries the limit: both, different limits — two bars", async () => { + const windows = await windowsFor({ + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [scoped(model("Fable"), 2)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); + }); + + it("which representation carries the limit: neither", async () => { + const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); + expect(windows.map((w) => w.id)).toEqual(["weekly"]); + }); + + const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; + + it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); + expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); + }); + + it("identity: a surface never matches a legacy model window of the same name", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(surface("Omelette"), 30)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); + expect(windows[0]).toMatchObject({ usedPct: 12 }); + expect(windows[1]).toMatchObject({ usedPct: 30 }); + }); + + it("identity: a model and a surface of the same name stay apart", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); + }); + + it("identity: ids decide when both sides have one", async () => { + const windows = await windowsFor({ + limits: [ + scoped(model("Fable-Pro", "fable-pro"), 4), + scoped(model("Fable_Pro", "fable_pro"), 9), + ], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); + }); + + it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), + ]); + }); + + it("identity: a renamed scope keeps its id when the API supplies one", async () => { + const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); + const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); + expect(before[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.label).toBe("Weekly · Fable 5"); + }); + + it("identity: a limit keeps one id whichever representation carries it", async () => { + const viaLegacy = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); + expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); + }); + + it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { + const windows = await windowsFor({ + five_hour: { utilization: 6, resets_at: RESETS }, + seven_day: { utilization: 23, resets_at: RESETS }, + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [ + { kind: "session", percent: 6, resets_at: RESETS, scope: null }, + { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, + scoped(model("Fable"), 2), + ], + }); + expect(windows.map((w) => w.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_opus", + "weekly_model_fable", + ]); + }); +});