From 66e5f7bb7e0caccb447aceb1deffa3ba1354fded Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 25 Apr 2026 20:45:07 -0400 Subject: [PATCH 1/4] feat(usage-budget): tier dispatch for session/credit/unsupported variants [GET-20] The usage endpoint returns a different payload per account tier; rendering half-populated bars on Enterprise accounts has been the visible bug since April. UsageLimitsData and UsageBudgetResult are now discriminated unions on kind, lib/usage-limits-parser.ts is the single classifier from raw JSON, and the budget agent branches on kind to produce a tier-appropriate render contract (session windows, monthly credit, or unsupported). Conversation store re-tags pre-GET-20 records as session on read so the upgrade is forward-compatible without a write migration. --- lib/conversation-store.ts | 28 ++++++- lib/message-types.ts | 106 +++++++++++++++++++++++- lib/usage-budget.ts | 163 +++++++++++++++++++++++++++++++------ lib/usage-limits-parser.ts | 147 +++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+), 27 deletions(-) create mode 100644 lib/usage-limits-parser.ts diff --git a/lib/conversation-store.ts b/lib/conversation-store.ts index 9f8d651..600e41e 100644 --- a/lib/conversation-store.ts +++ b/lib/conversation-store.ts @@ -730,12 +730,38 @@ export async function storeUsageLimits(accountId: string, limits: UsageLimitsDat * Returns null if no data has been stored yet (e.g. user has not loaded claude.ai * with the extension active since this feature shipped). * + * Forward-compatible read shim: records written before tier dispatch (GET-20) + * have no `kind` field. They are always the session shape (Pro/Personal was + * the only tier we wrote to storage), so we tag them in-memory as 'session' + * and return. Storage is left untouched; the next write overwrites with the + * fully-tagged shape, and the legacy record never leaks back out. + * * @param accountId - Organization UUID */ export async function getUsageLimits(accountId: string): Promise { const key = usageLimitsKey(accountId); const data = await store().get(key); - return (data[key] as UsageLimitsData | undefined) ?? null; + const raw = data[key]; + if (!raw || typeof raw !== 'object') return null; + + const record = raw as Partial & { + fiveHour?: unknown; + sevenDay?: unknown; + }; + + // Already-tagged (session/credit/unsupported): return verbatim. + if (record.kind === 'session' || record.kind === 'credit' || record.kind === 'unsupported') { + return record as UsageLimitsData; + } + + // Untagged legacy record: only the session shape was ever written. Detect + // it by the two windows and re-emit as a session variant. Anything else + // we cannot place gets dropped to null so downstream code does not have + // to defend against half-typed records. + if (record.fiveHour && record.sevenDay) { + return { ...(record as Omit), kind: 'session' } as UsageLimitsData; + } + return null; } // ── Usage delta log ─────────────────────────────────────────────────────────── diff --git a/lib/message-types.ts b/lib/message-types.ts index 5da5135..8c3f6f4 100644 --- a/lib/message-types.ts +++ b/lib/message-types.ts @@ -234,8 +234,19 @@ export interface UsageLimitWindow { * Structured usage data fetched from /api/organizations/{orgId}/usage. * Stored as `usageLimits:{accountId}` in chrome.storage.local. * Overwritten on each fetch; not appended. + * + * Discriminated by `kind` because the endpoint returns three different shapes + * depending on the account tier. Personal/Pro/Max see session + weekly windows; + * Enterprise sees a monthly credit pool under `extra_usage`; some account types + * (e.g. Teams without usage exposure) return a 200 with neither shape and + * land as `unsupported`. The parser (lib/usage-limits-parser.ts) is the only + * code that produces these values; everything downstream branches on `kind`. */ -export interface UsageLimitsData { +export type UsageLimitsData = UsageLimitsSession | UsageLimitsCredit | UsageLimitsUnsupported; + +/** Personal / Pro / Max / Team. Session + weekly rolling windows. */ +export interface UsageLimitsSession { + kind: 'session'; /** 5-hour rolling session window. "Current session" on the Usage page. */ fiveHour: UsageLimitWindow; /** 7-day rolling window. "Weekly limits" on the Usage page. */ @@ -244,6 +255,32 @@ export interface UsageLimitsData { capturedAt: number; } +/** + * Enterprise. Monthly credit pool exposed under `extra_usage`. + * `monthlyLimitCents` and `usedCents` are integers in cents (50000 = $500.00). + * Currency is whatever the endpoint reports (USD on every Enterprise account + * we have observed, but stored verbatim so the UI never has to guess). + */ +export interface UsageLimitsCredit { + kind: 'credit'; + monthlyLimitCents: number; + usedCents: number; + /** 0-100. Endpoint computes this server-side; we pass it through. */ + utilizationPct: number; + currency: string; + capturedAt: number; +} + +/** + * Endpoint returned 200 but neither the session shape nor the credit shape + * was recognizable. Surfaced so the dashboard can tell the user we cannot + * read their usage instead of silently rendering empty bars. + */ +export interface UsageLimitsUnsupported { + kind: 'unsupported'; + capturedAt: number; +} + /** * Zone classification for usage budget display. * Determined by the higher of session and weekly utilization. @@ -255,8 +292,16 @@ export type BudgetZone = 'comfortable' | 'moderate' | 'tight' | 'critical'; * Output of the Usage Budget Agent (lib/usage-budget.ts). * All derived from UsageLimitsData; no estimation, no velocity. * Every value is either exact (from the endpoint) or clearly labeled as unavailable. + * + * Discriminated on `kind` to mirror UsageLimitsData. Each variant is a complete + * render contract for one account tier. Components narrow on `kind` and read + * only the fields that exist on that variant. */ -export interface UsageBudgetResult { +export type UsageBudgetResult = UsageBudgetSession | UsageBudgetCredit | UsageBudgetUnsupported; + +/** Session-tier render contract: the original Pro/Personal layout, unchanged. */ +export interface UsageBudgetSession { + kind: 'session'; /** Session utilization as a percentage (0-100). Matches Settings > Usage exactly. */ sessionPct: number; /** Weekly utilization as a percentage (0-100). Matches Settings > Usage exactly. */ @@ -271,15 +316,54 @@ export interface UsageBudgetResult { statusLabel: string; } +/** + * Credit-tier render contract: Enterprise monthly spend. + * Cents are kept as integers; the UI formats them via the currency code at render time. + */ +export interface UsageBudgetCredit { + kind: 'credit'; + monthlyLimitCents: number; + usedCents: number; + /** 0-100. Drives the single spend bar. */ + utilizationPct: number; + /** ISO 4217 currency code from the endpoint (e.g. "USD"). */ + currency: string; + /** "Resets May 1" — first day of next calendar month, locale-formatted. */ + resetLabel: string; + /** Zone derived from utilizationPct. Drives bar color even though there is no second window. */ + zone: BudgetZone; + /** One-liner for the card's primary text. E.g. "$304.91 of $500.00 spent". */ + statusLabel: string; +} + +/** + * Unsupported-tier render contract. No values to surface; the card renders an + * explicit "we can't read this account type" message instead of empty bars. + */ +export interface UsageBudgetUnsupported { + kind: 'unsupported'; +} + // ── Usage Limits Bridge Messages ────────────────────────────────────────────── /** * Posted from the content script to the background when usage data is fetched. * The content script fetches /api/organizations/{orgId}/usage directly * (same session cookies, no auth needed) and forwards the parsed result here. + * + * Discriminated on `kind` so the background handler can rebuild the matching + * UsageLimitsData variant without having to re-parse the raw response. The + * content script has already done the parsing; this message is the wire format + * carrying the typed result across the runtime boundary. */ -export interface StoreUsageLimitsMessage { +export type StoreUsageLimitsMessage = + | StoreUsageLimitsSessionMessage + | StoreUsageLimitsCreditMessage + | StoreUsageLimitsUnsupportedMessage; + +export interface StoreUsageLimitsSessionMessage { type: 'STORE_USAGE_LIMITS'; + kind: 'session'; organizationId: string; fiveHourUtilization: number; fiveHourResetsAt: string; @@ -287,6 +371,22 @@ export interface StoreUsageLimitsMessage { sevenDayResetsAt: string; } +export interface StoreUsageLimitsCreditMessage { + type: 'STORE_USAGE_LIMITS'; + kind: 'credit'; + organizationId: string; + monthlyLimitCents: number; + usedCents: number; + utilizationPct: number; + currency: string; +} + +export interface StoreUsageLimitsUnsupportedMessage { + type: 'STORE_USAGE_LIMITS'; + kind: 'unsupported'; + organizationId: string; +} + // ── Token Economics Messages ──────────────────────────────────────────────── /** diff --git a/lib/usage-budget.ts b/lib/usage-budget.ts index 2f05163..97cbd58 100644 --- a/lib/usage-budget.ts +++ b/lib/usage-budget.ts @@ -9,13 +9,26 @@ // Caller: useDashboardData.ts reads storage and calls computeUsageBudget() // Consumer: UsageBudgetCard.tsx renders the result // +// Tier dispatch: +// The endpoint exposes a different shape per account tier (see +// lib/usage-limits-parser.ts). This agent branches on `limits.kind` and +// returns the matching UsageBudgetResult variant. Render code never +// computes its own status text or zone — every label that ends up on the +// user's screen comes from here. +// // Design principles (mirrors all other lib/ agents): // - Pure functions only. No DOM, no chrome.*, no side effects. // - Typed input, typed output. No implicit any. // - Every value is derived from exact Anthropic data: no estimation, no guessing. -// - If the data is unavailable, the result says so clearly (statusLabel fallback). +// - If the data is unavailable, the result says so clearly via the kind. -import type { UsageLimitsData, UsageBudgetResult, BudgetZone } from './message-types'; +import type { + UsageLimitsData, + UsageBudgetResult, + UsageBudgetSession, + UsageBudgetCredit, + BudgetZone, +} from './message-types'; // ── Zone classification ─────────────────────────────────────────────────────── @@ -83,7 +96,7 @@ function formatWeeklyResetLabel(resetsAt: string): string { // ── Status label ───────────────────────────────────────────────────────────── /** - * Build the one-liner primary text for the UsageBudgetCard. + * Build the one-liner primary text for the UsageBudgetCard (session variant). * Session is the more urgent window (resets every 5 hours), so it leads. * Examples: * comfortable: "11% used; resets in 53 min" @@ -91,7 +104,7 @@ function formatWeeklyResetLabel(resetsAt: string): string { * tight: "82% used; resets in 23 min" * critical: "94% used; session nearly exhausted" */ -function buildStatusLabel(sessionPct: number, sessionMinutes: number, zone: BudgetZone): string { +function buildSessionStatusLabel(sessionPct: number, sessionMinutes: number, zone: BudgetZone): string { const pctStr = `${Math.round(sessionPct)}% used`; if (zone === 'critical') { return `${pctStr}; session nearly exhausted`; @@ -100,32 +113,136 @@ function buildStatusLabel(sessionPct: number, sessionMinutes: number, zone: Budg return `${pctStr}; resets in ${countdown}`; } +// ── Credit-tier helpers ────────────────────────────────────────────────────── + +// `Intl.NumberFormat` constructors are not free; on a hot card render we would +// build two per call (used + monthly). Cache by currency code — the same code +// is reused across renders for one account, and the cache is bounded by the +// number of currencies Anthropic actually returns (one per account, ever). +// `null` marks codes Intl rejected so we do not retry the constructor. +const currencyFormatterCache = new Map(); + +function currencyFormatter(currency: string): Intl.NumberFormat | null { + if (currencyFormatterCache.has(currency)) { + return currencyFormatterCache.get(currency) ?? null; + } + try { + const fmt = new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + currencyDisplay: 'symbol', + }); + currencyFormatterCache.set(currency, fmt); + return fmt; + } catch { + // Unknown currency code: cache the failure so we do not retry. + currencyFormatterCache.set(currency, null); + return null; + } +} + +/** + * Format an integer cent amount in the given currency. + * 30491 USD → "$304.91". Falls back to a portable "USD 304.91" form for any + * currency code Intl cannot resolve, so the card never shows NaN or an empty + * string. + */ +function formatCents(cents: number, currency: string): string { + const amount = cents / 100; + const fmt = currencyFormatter(currency); + return fmt ? fmt.format(amount) : `${currency} ${amount.toFixed(2)}`; +} + +/** + * "Resets May 1" — first day of the next calendar month, locale-formatted. + * Anthropic resets Enterprise credit pools on the first of the month, so the + * label is deterministic from `now` alone; the endpoint does not return a + * resets_at for credit responses. + */ +function buildCreditResetLabel(now: number): string { + const today = new Date(now); + // First-of-next-month in the user's local calendar. Date math handles year + // rollover automatically (December 1 → January 1). + const reset = new Date(today.getFullYear(), today.getMonth() + 1, 1); + const formatted = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + }).format(reset); + return `Resets ${formatted}`; +} + // ── Main agent function ─────────────────────────────────────────────────────── /** * Transform raw Anthropic usage data into a display-ready budget result. * + * Branches on `limits.kind`: + * - 'session' → session windows + zone from max(session, weekly) + status text + * - 'credit' → monthly spend + zone from utilizationPct + "$X of $Y spent" + * - 'unsupported' → passthrough; the card renders a "not supported" message + * * @param limits - Parsed response from /api/organizations/{orgId}/usage * @param now - Current Unix ms timestamp (injectable for testability) - * @returns UsageBudgetResult ready for UsageBudgetCard to render */ export function computeUsageBudget(limits: UsageLimitsData, now: number): UsageBudgetResult { - const sessionPct = limits.fiveHour.utilization; - const weeklyPct = limits.sevenDay.utilization; - - // Zone is driven by whichever window is more exhausted. - const zone = classifyZone(Math.max(sessionPct, weeklyPct)); - - const sessionMinutesUntilReset = minutesUntilReset(limits.fiveHour.resetsAt, now); - const weeklyResetLabel = formatWeeklyResetLabel(limits.sevenDay.resetsAt); - const statusLabel = buildStatusLabel(sessionPct, sessionMinutesUntilReset, zone); - - return { - sessionPct, - weeklyPct, - sessionMinutesUntilReset, - weeklyResetLabel, - zone, - statusLabel, - }; + if (limits.kind === 'session') { + const sessionPct = limits.fiveHour.utilization; + const weeklyPct = limits.sevenDay.utilization; + + // Zone is driven by whichever window is more exhausted. + const zone = classifyZone(Math.max(sessionPct, weeklyPct)); + + const sessionMinutesUntilReset = minutesUntilReset(limits.fiveHour.resetsAt, now); + const weeklyResetLabel = formatWeeklyResetLabel(limits.sevenDay.resetsAt); + const statusLabel = buildSessionStatusLabel(sessionPct, sessionMinutesUntilReset, zone); + + const result: UsageBudgetSession = { + kind: 'session', + sessionPct, + weeklyPct, + sessionMinutesUntilReset, + weeklyResetLabel, + zone, + statusLabel, + }; + return result; + } + + if (limits.kind === 'credit') { + const zone = classifyZone(limits.utilizationPct); + const spent = formatCents(limits.usedCents, limits.currency); + const total = formatCents(limits.monthlyLimitCents, limits.currency); + const result: UsageBudgetCredit = { + kind: 'credit', + monthlyLimitCents: limits.monthlyLimitCents, + usedCents: limits.usedCents, + utilizationPct: limits.utilizationPct, + currency: limits.currency, + resetLabel: buildCreditResetLabel(now), + zone, + statusLabel: `${spent} of ${total} spent`, + }; + return result; + } + + // Unsupported account type: nothing to compute. The card branches on this + // kind and renders an explicit "can't read this account" message. + return { kind: 'unsupported' }; +} + +// ── Delta tracking helper ───────────────────────────────────────────────────── + +/** + * Return the percentage value the content script should track turn-over-turn + * for delta computation. Session tier tracks the 5-hour window; credit tier + * tracks monthly utilization. The label changes (% of session vs % of monthly) + * but the math is the same — subtract before from after to get the cost of one + * message in tier-appropriate units. + * + * Typed to reject the unsupported variant: there is nothing to track when the + * endpoint shape was unrecognized, and forcing the caller to gate first keeps + * the helper itself total. + */ +export function getTrackedUtilization(budget: UsageBudgetSession | UsageBudgetCredit): number { + return budget.kind === 'session' ? budget.sessionPct : budget.utilizationPct; } diff --git a/lib/usage-limits-parser.ts b/lib/usage-limits-parser.ts new file mode 100644 index 0000000..3e752f6 --- /dev/null +++ b/lib/usage-limits-parser.ts @@ -0,0 +1,147 @@ +// lib/usage-limits-parser.ts - Tier dispatch for /api/organizations/{orgId}/usage +// +// Anthropic's usage endpoint returns three distinct shapes depending on the +// account tier of the requesting org. There is no `tier` field; we have to +// dispatch by which sub-objects are populated: +// +// Pro / Personal / Max / Team +// → `five_hour` and `seven_day` are objects with utilization + resets_at +// → `extra_usage.is_enabled` is false (or extra_usage is absent) +// +// Enterprise +// → `five_hour` and `seven_day` are explicitly null +// → `extra_usage.is_enabled` is true with monthly_limit / used_credits in cents +// +// Unrecognized 200 (e.g. some Teams configurations, future tiers) +// → neither shape is fully populated +// +// Anything that is not JSON, or JSON we cannot recognize at all, falls through +// as `null` so the caller can distinguish "we got something we cannot use yet" +// (which still gives the user a "not supported" empty state) from "the request +// failed" (which keeps the previous render in place). +// +// This module is the only place that touches the raw endpoint shape. Every +// downstream consumer reads typed UsageLimitsData; the wire format does not +// leak past this file. + +import type { UsageLimitsData } from './message-types'; + +// ── Raw endpoint shape (defensive — every field optional) ───────────────────── +// We deliberately type these as `unknown`-friendly: a field may be missing, +// null, or the wrong type. The dispatch helpers below validate what they need. + +interface RawUsageWindow { + utilization?: unknown; + resets_at?: unknown; +} + +interface RawExtraUsage { + is_enabled?: unknown; + monthly_limit?: unknown; + used_credits?: unknown; + utilization?: unknown; + currency?: unknown; +} + +interface RawUsageResponse { + five_hour?: RawUsageWindow | null; + seven_day?: RawUsageWindow | null; + extra_usage?: RawExtraUsage | null; +} + +// ── Type guards ────────────────────────────────────────────────────────────── + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * True when both windows carry a numeric utilization and a string resets_at. + * This is the pre-Enterprise shape we have always handled. + */ +function isSessionShape(raw: RawUsageResponse): boolean { + const five = raw.five_hour; + const seven = raw.seven_day; + return ( + isObject(five) && + typeof five.utilization === 'number' && + typeof five.resets_at === 'string' && + isObject(seven) && + typeof seven.utilization === 'number' && + typeof seven.resets_at === 'string' + ); +} + +/** + * True when extra_usage is enabled with a complete cents-and-utilization payload. + * We require all four fields because rendering a half-populated bar would be + * worse than rendering the unsupported empty state. + */ +function isCreditShape(raw: RawUsageResponse): boolean { + const extra = raw.extra_usage; + return ( + isObject(extra) && + extra.is_enabled === true && + typeof extra.monthly_limit === 'number' && + typeof extra.used_credits === 'number' && + typeof extra.utilization === 'number' && + typeof extra.currency === 'string' + ); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Parse a raw /api/organizations/{orgId}/usage response into typed UsageLimitsData. + * + * Returns: + * - { kind: 'session', ... } for the Pro/Personal/Max/Team window shape + * - { kind: 'credit', ... } for the Enterprise extra_usage shape + * - { kind: 'unsupported', ... } when the response is a recognizable object + * but neither shape applies (renders an + * explicit "can't read this account" state) + * - null when the input is not even an object we can + * inspect (treat as transient failure; caller + * leaves any previous render in place) + * + * Session takes priority over credit if both shapes happen to be populated. + * On every observed account so far, exactly one of the two is present, but we + * still pick a winner so the function is total. + */ +export function parseUsageResponse(json: unknown): UsageLimitsData | null { + if (!isObject(json)) return null; + const raw = json as RawUsageResponse; + const capturedAt = Date.now(); + + if (isSessionShape(raw)) { + // Non-null assertions are safe: isSessionShape proved both windows + // exist with the right primitive types. + const five = raw.five_hour as { utilization: number; resets_at: string }; + const seven = raw.seven_day as { utilization: number; resets_at: string }; + return { + kind: 'session', + fiveHour: { utilization: five.utilization, resetsAt: five.resets_at }, + sevenDay: { utilization: seven.utilization, resetsAt: seven.resets_at }, + capturedAt, + }; + } + + if (isCreditShape(raw)) { + const extra = raw.extra_usage as { + monthly_limit: number; + used_credits: number; + utilization: number; + currency: string; + }; + return { + kind: 'credit', + monthlyLimitCents: extra.monthly_limit, + usedCents: extra.used_credits, + utilizationPct: extra.utilization, + currency: extra.currency, + capturedAt, + }; + } + + return { kind: 'unsupported', capturedAt }; +} From 9b5a92e18d7e3a6f835e15df31505c045c893348 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 25 Apr 2026 20:45:15 -0400 Subject: [PATCH 2/4] feat(content-script): wire tier-aware fetch + delta tracking [GET-20] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline session-shape validation in fetchAndStoreUsageLimits with parseUsageResponse so all three tier variants flow through the same dispatch path. STORE_USAGE_LIMITS becomes a discriminated message; the background handler rebuilds the matching UsageLimitsData variant without re-parsing. Delta tracking now uses getTrackedUtilization, which returns sessionPct on session and utilizationPct on credit so the math is the same on both tiers in tier-appropriate units. Overlay state narrows to renderable variants only — the unsupported budget has no UI to draw and is gated out at the call site. --- entrypoints/background.ts | 34 +++++++++--- entrypoints/claude-ai.content.ts | 89 ++++++++++++++++++++------------ lib/overlay-state.ts | 24 +++++++-- 3 files changed, 102 insertions(+), 45 deletions(-) diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 67cb565..cea650e 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -389,14 +389,34 @@ export default defineBackground({ // Usage limit data fetched from /api/organizations/{orgId}/usage by the content script. // Stored as usageLimits:{orgId} in chrome.storage.local (single record, overwritten). // Powers the Usage Budget card in the side panel dashboard. + // + // The content script has already classified the response into one of three + // tier variants. We rebuild the matching UsageLimitsData here rather than + // re-parsing the raw endpoint, which keeps the wire format strongly typed + // and the storage layer indifferent to which tier it is persisting. if (message.type === 'STORE_USAGE_LIMITS') { - const { organizationId, fiveHourUtilization, fiveHourResetsAt, sevenDayUtilization, sevenDayResetsAt } = message; - const limits: UsageLimitsData = { - fiveHour: { utilization: fiveHourUtilization, resetsAt: fiveHourResetsAt }, - sevenDay: { utilization: sevenDayUtilization, resetsAt: sevenDayResetsAt }, - capturedAt: Date.now(), - }; - storeUsageLimits(organizationId, limits) + const capturedAt = Date.now(); + let limits: UsageLimitsData; + if (message.kind === 'session') { + limits = { + kind: 'session', + fiveHour: { utilization: message.fiveHourUtilization, resetsAt: message.fiveHourResetsAt }, + sevenDay: { utilization: message.sevenDayUtilization, resetsAt: message.sevenDayResetsAt }, + capturedAt, + }; + } else if (message.kind === 'credit') { + limits = { + kind: 'credit', + monthlyLimitCents: message.monthlyLimitCents, + usedCents: message.usedCents, + utilizationPct: message.utilizationPct, + currency: message.currency, + capturedAt, + }; + } else { + limits = { kind: 'unsupported', capturedAt }; + } + storeUsageLimits(message.organizationId, limits) .then(() => sendResponse({ ok: true })) .catch((err) => { console.error('[LCO-ERROR] Failed to store usage limits:', err); diff --git a/entrypoints/claude-ai.content.ts b/entrypoints/claude-ai.content.ts index 49754fa..7335cef 100644 --- a/entrypoints/claude-ai.content.ts +++ b/entrypoints/claude-ai.content.ts @@ -2,11 +2,12 @@ // Thin orchestrator: validates bridge messages, drives state transitions, renders overlay. // All logic lives in imported modules; this file only wires them together. -import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, UsageLimitsData, UsageBudgetResult } from '../lib/message-types'; +import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, UsageBudgetResult } from '../lib/message-types'; import { LCO_NAMESPACE } from '../lib/message-types'; import { isValidBridgeSchema } from '../lib/bridge-validation'; import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget } from '../lib/overlay-state'; -import { computeUsageBudget } from '../lib/usage-budget'; +import { computeUsageBudget, getTrackedUtilization } from '../lib/usage-budget'; +import { parseUsageResponse } from '../lib/usage-limits-parser'; import { computePreSubmitEstimate } from '../lib/pre-submit'; import { createOverlay } from '../ui/overlay'; import { showEnableBanner } from '../ui/enable-banner'; @@ -79,31 +80,44 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise { /* non-critical */ }); - const capturedAt = Date.now(); - const limits: UsageLimitsData = { - fiveHour: { utilization: fiveHour.utilization, resetsAt: fiveHour.resets_at }, - sevenDay: { utilization: sevenDay.utilization, resetsAt: sevenDay.resets_at }, - capturedAt, - }; - return computeUsageBudget(limits, capturedAt); + const rawJson: unknown = await response.json(); + + // The parser is the single source of tier dispatch. It returns null only + // when the body is not even an object we can inspect; in that case we + // pretend the request failed and leave any previous render in place. + const limits = parseUsageResponse(rawJson); + if (!limits) return null; + + // Forward the typed result to the background. The kind discriminator + // tells the handler which UsageLimitsData variant to rebuild. + const storeMessage: StoreUsageLimitsMessage = limits.kind === 'session' + ? { + type: 'STORE_USAGE_LIMITS', + kind: 'session', + organizationId: orgId, + fiveHourUtilization: limits.fiveHour.utilization, + fiveHourResetsAt: limits.fiveHour.resetsAt, + sevenDayUtilization: limits.sevenDay.utilization, + sevenDayResetsAt: limits.sevenDay.resetsAt, + } + : limits.kind === 'credit' + ? { + type: 'STORE_USAGE_LIMITS', + kind: 'credit', + organizationId: orgId, + monthlyLimitCents: limits.monthlyLimitCents, + usedCents: limits.usedCents, + utilizationPct: limits.utilizationPct, + currency: limits.currency, + } + : { + type: 'STORE_USAGE_LIMITS', + kind: 'unsupported', + organizationId: orgId, + }; + browser.runtime.sendMessage(storeMessage).catch(() => { /* non-critical */ }); + + return computeUsageBudget(limits, limits.kind === 'unsupported' ? Date.now() : limits.capturedAt); } catch { // Network errors are silently ignored; the dashboard shows stale data. return null; @@ -291,11 +305,13 @@ async function initializeMonitoring(): Promise { // Fetch usage limits now that we have the org ID. Populates the // Usage Budget card in the side panel and weekly bar on the overlay. - // Capture sessionPct as the initial before-snapshot so the first - // STREAM_COMPLETE can compute a delta. + // Capture the tier-appropriate utilization (session% on Pro, + // monthly% on Enterprise) as the initial before-snapshot so the + // first STREAM_COMPLETE can compute a delta in matching units. + // The unsupported variant has nothing to track or display. fetchAndStoreUsageLimits(currentOrgId).then(budget => { - if (budget !== null) { - lastKnownUtilization = budget.sessionPct; + if (budget !== null && budget.kind !== 'unsupported') { + lastKnownUtilization = getTrackedUtilization(budget); state = applyUsageBudget(state, budget); overlay.render(state); } @@ -467,7 +483,13 @@ async function initializeMonitoring(): Promise { // fetchAndStoreUsageLimits catches all errors internally; it never // throws. The .then() always runs. fetchAndStoreUsageLimits(orgId).then(budgetAfter => { - const utilizationAfter = budgetAfter?.sessionPct ?? null; + // Tracked utilization is tier-aware: 5-hour session % on Pro, + // monthly credit % on Enterprise. The unsupported variant has + // nothing to track, so we leave the snapshot null and skip + // the delta math entirely. + const utilizationAfter = (budgetAfter !== null && budgetAfter.kind !== 'unsupported') + ? getTrackedUtilization(budgetAfter) + : null; // Update the cached value so the next STREAM_COMPLETE has a // fresh before-snapshot. Only update on valid (non-null) reads. if (utilizationAfter !== null) { @@ -488,7 +510,8 @@ async function initializeMonitoring(): Promise { // Apply fresh budget to overlay state. Combine with delta update in // one state object so the render below covers both changes. - if (budgetAfter !== null) { + // Unsupported variants have no UI to render, so they never enter state. + if (budgetAfter !== null && budgetAfter.kind !== 'unsupported') { state = applyUsageBudget(state, budgetAfter); } diff --git a/lib/overlay-state.ts b/lib/overlay-state.ts index 7989981..56f7a20 100644 --- a/lib/overlay-state.ts +++ b/lib/overlay-state.ts @@ -4,11 +4,18 @@ // The content script calls these and passes the result to overlay.render(). import { calculateCost, getContextWindowSize } from './pricing'; -import type { TabState, UsageBudgetResult } from './message-types'; +import type { TabState, UsageBudgetSession, UsageBudgetCredit } from './message-types'; import type { HealthScore } from './health-score'; import type { ConversationRecord } from './conversation-store'; import type { PreSubmitEstimate } from './pre-submit'; +/** + * Renderable budget variants only. The unsupported variant has nothing for + * the in-page overlay to draw, so it never reaches state — the content + * script gates the call before applyUsageBudget runs. + */ +export type RenderableBudget = UsageBudgetSession | UsageBudgetCredit; + export interface OverlayState { lastRequest: { inputTokens: number; @@ -42,11 +49,13 @@ export interface OverlayState { */ draftEstimate: PreSubmitEstimate | null; /** - * Weekly cap utilization derived from /api/organizations/{orgId}/usage. + * Tier-aware usage budget derived from /api/organizations/{orgId}/usage. * Null until the first successful fetchAndStoreUsageLimits call, or when * the extension is running outside of claude.ai (no usage endpoint available). + * Only renderable variants land here; the unsupported variant is filtered + * out at the call site (the overlay has no empty-state UI for it). */ - usageBudget: UsageBudgetResult | null; + usageBudget: RenderableBudget | null; } export const INITIAL_STATE: Readonly = { @@ -176,7 +185,12 @@ export function clearDraftEstimate(state: OverlayState): OverlayState { return { ...state, draftEstimate: null }; } -/** Apply a fresh UsageBudgetResult. Called after every fetchAndStoreUsageLimits call. */ -export function applyUsageBudget(state: OverlayState, budget: UsageBudgetResult): OverlayState { +/** + * Apply a fresh renderable budget. Called after every fetchAndStoreUsageLimits + * call. Typed to reject the unsupported variant: the overlay has no UI for an + * unrecognized account type, and forcing the caller to gate first keeps the + * bar-rendering code total. + */ +export function applyUsageBudget(state: OverlayState, budget: RenderableBudget): OverlayState { return { ...state, usageBudget: budget }; } From ceb0daf1d942ba13d0593b98eb4bc27803987bab Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 25 Apr 2026 20:45:24 -0400 Subject: [PATCH 3/4] feat(sidepanel,overlay): tier-variant render + tier-aware delta label [GET-20] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UsageBudgetCard branches on budget.kind: session keeps the existing session+weekly layout, credit renders a single monthly spend bar with the agent's "$X of $Y spent" label and an Enterprise pill, and unsupported shows "Saar can't read usage on this account type yet". A new isClaudeTab prop disambiguates the empty state — off-tab users get a prompt to open claude.ai instead. The in-page overlay's weekly bar is now scoped to session budgets (credit has no weekly window) and the "this reply" delta label switches to "% of monthly" when the budget is credit-tier, since lastDeltaUtilization is tracked in tier-appropriate units. --- entrypoints/sidepanel/App.tsx | 7 +- .../sidepanel/components/UsageBudgetCard.tsx | 96 +++++++++++++++++-- ui/overlay.ts | 17 +++- 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/entrypoints/sidepanel/App.tsx b/entrypoints/sidepanel/App.tsx index 4aa652a..c609087 100644 --- a/entrypoints/sidepanel/App.tsx +++ b/entrypoints/sidepanel/App.tsx @@ -64,10 +64,11 @@ export default function App() { )} {/* Usage Budget: live session data. budget is null when !isClaudeTab - (cleared by useDashboardData); UsageBudgetCard renders its own - empty state when budget is null. */} + (cleared by useDashboardData); UsageBudgetCard branches on the + tier variant (session/credit/unsupported) and chooses the + empty-state copy from `isClaudeTab`. */} - + diff --git a/entrypoints/sidepanel/components/UsageBudgetCard.tsx b/entrypoints/sidepanel/components/UsageBudgetCard.tsx index 791f17e..7602bad 100644 --- a/entrypoints/sidepanel/components/UsageBudgetCard.tsx +++ b/entrypoints/sidepanel/components/UsageBudgetCard.tsx @@ -5,19 +5,37 @@ // page load and after each response, stored as usageLimits:{accountId}). // The numbers here match claude.ai/settings/limits exactly -- no estimation. // -// Props: UsageBudgetResult from lib/usage-budget.ts (the Usage Budget Agent). -// When budget is null, renders a placeholder prompt to load claude.ai. +// Tier dispatch: +// - session → original Pro/Personal/Max layout: session bar + weekly bar +// - credit → Enterprise: a single monthly spend bar in dollars +// - unsupported → explicit "we can't read this account type" empty state +// +// Empty states: +// - !isClaudeTab && !budget → prompt the user to open claude.ai +// - isClaudeTab but no usable budget → tell them this account isn't supported +// +// Props: typed budget result from lib/usage-budget.ts plus the tab-awareness +// flag from useDashboardData.ts. Components further down receive pre-computed +// state and never touch chrome.* directly. import React from 'react'; -import type { UsageBudgetResult, BudgetZone } from '../../../lib/message-types'; +import type { UsageBudgetResult, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../../lib/message-types'; import { classifyZone } from '../../../lib/usage-budget'; interface Props { budget: UsageBudgetResult | null; + /** + * True when the active tab is on claude.ai. Drives the empty-state copy: + * off-tab users get a prompt to open claude.ai; on-tab users with no usable + * data get the "account type not supported" message. + */ + isClaudeTab: boolean; } -// Zone-to-label mapping for the dot and fills. -// Mirrors the health dot pattern in ActiveConversation.tsx. +// Zone-to-label mapping for the dot and fills. Mirrors the health dot +// pattern in ActiveConversation.tsx. The credit variant replaces the zone +// name with a tier label ("Enterprise") because the four-zone vocabulary +// does not translate cleanly to a single monthly bar. const ZONE_LABELS: Record = { comfortable: 'Comfortable', moderate: 'Moderate', @@ -25,8 +43,10 @@ const ZONE_LABELS: Record = { critical: 'Critical', }; -export default function UsageBudgetCard({ budget }: Props) { - if (!budget) { +export default function UsageBudgetCard({ budget, isClaudeTab }: Props) { + // No data at all + the user is on a tab where we cannot fetch any. + // Surface the obvious next action rather than a silent empty card. + if (!budget && !isClaudeTab) { return (

Open claude.ai to load usage data

@@ -34,6 +54,28 @@ export default function UsageBudgetCard({ budget }: Props) { ); } + // We are on claude.ai but the response did not match either tier shape we + // know about. This is the honest state for Teams accounts (and possibly + // future tiers): we fetched, parsed, and found nothing actionable. Better + // than silently rendering empty bars that look like a fresh session. + if (!budget || budget.kind === 'unsupported') { + return ( +
+

Saar can't read usage on this account type yet

+
+ ); + } + + // Session and credit each get their own render path; the discriminator on + // `budget.kind` lets TypeScript narrow into the right field set. + return budget.kind === 'session' + ? + : ; +} + +// ── Session variant (Pro / Personal / Max) ─────────────────────────────────── + +function SessionBudget({ budget }: { budget: UsageBudgetSession }) { const { sessionPct, weeklyPct, sessionMinutesUntilReset, weeklyResetLabel, zone, statusLabel } = budget; // Clamp to [0, 100] for the bar fill. The API returns 0-100 already, but @@ -91,6 +133,46 @@ export default function UsageBudgetCard({ budget }: Props) { ); } +// ── Credit variant (Enterprise) ────────────────────────────────────────────── + +function CreditBudget({ budget }: { budget: UsageBudgetCredit }) { + const { utilizationPct, zone, statusLabel, resetLabel } = budget; + const safePct = Math.min(Math.max(utilizationPct, 0), 100); + + return ( +
+ {/* Header: zone dot drives the bar color, but the label is the tier + name. "Comfortable / Moderate / Tight / Critical" applies to a + rolling window — for a monthly credit pool, the user just wants + to know what tier they're on. */} +
+ + Enterprise +
+ + {/* Primary status line: "$304.91 of $500.00 spent" */} +

{statusLabel}

+ + {/* Single monthly spend bar */} +
+ Monthly +
+
+
+ {Math.round(safePct)}% +
+ + {/* Reset line: "Resets May 1" */} +
+ {resetLabel} +
+
+ ); +} + // ── Helpers ─────────────────────────────────────────────────────────────────── function formatSessionReset(minutes: number): string { diff --git a/ui/overlay.ts b/ui/overlay.ts index 5d8cfc0..3cb4685 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -343,11 +343,16 @@ export function createOverlay(): OverlayHandle { if (elCurrentRequest && state.lastRequest) { const { inputTokens, outputTokens, cost } = state.lastRequest; - // Lead with exact session % when available (Anthropic endpoint, not estimated). + // Lead with exact tier-appropriate utilization when available + // (Anthropic endpoint, not estimated). The label tracks the budget + // variant so an Enterprise user sees "% of monthly" instead of the + // misleading "% of session" — the underlying number is monthly + // credit utilization on that tier. // Falls back to token/cost display when delta has not yet resolved. if (state.lastDeltaUtilization !== null) { + const window = state.usageBudget?.kind === 'credit' ? 'monthly' : 'session'; elCurrentRequest.textContent = - `${state.lastDeltaUtilization.toFixed(1)}% of session · ${fmtCost(cost)}`; + `${state.lastDeltaUtilization.toFixed(1)}% of ${window} · ${fmtCost(cost)}`; } else { elCurrentRequest.textContent = `~${fmt(inputTokens)} in · ~${fmt(outputTokens)} out · ${fmtCost(cost)}`; @@ -410,11 +415,15 @@ export function createOverlay(): OverlayHandle { } if (elWeeklyRow && elWeeklyFill && elWeeklyLabel) { + // Only the session tier exposes a weekly window. On credit and + // unsupported variants the bar would be meaningless (Enterprise has + // a monthly pool surfaced in the side panel; unsupported has nothing + // to show), so we keep the row hidden in those cases. const budget = state.usageBudget; - const visible = budget !== null; + const visible = budget !== null && budget.kind === 'session'; elWeeklyRow.style.display = visible ? '' : 'none'; if (visible) { - const pct = Math.min(Math.max(budget!.weeklyPct, 0), 100); + const pct = Math.min(Math.max(budget.weeklyPct, 0), 100); elWeeklyFill.style.transform = `scaleX(${pct / 100})`; elWeeklyFill.className = `lco-bar-fill lco-bar-fill--${classifyZone(pct)}`; elWeeklyLabel.textContent = `${Math.round(pct)}% weekly`; From 189a3142acd26647a52dc7e27c9012fa698e6fc9 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 25 Apr 2026 20:45:35 -0400 Subject: [PATCH 4/4] test(usage-budget): cover tier variants, parser, card render, delta label [GET-20] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New coverage: - usage-limits-parser.test.ts: all four parser outcomes (session, credit, unsupported, null) plus dispatch priority and malformed-input edges, anchored on the verbatim Enterprise fixture from 2026-04-23. - usage-budget.test.ts: credit variant zone boundaries, currency-aware "$X of $Y spent" label, Dec→Jan reset rollover, unknown-currency fallback, and getTrackedUtilization. - usage-budget-card.test.tsx: render tests for the three GET-20 ACs — Enterprise spend bar, Pro/Personal session+weekly layout, Teams / unrecognized empty state, plus the off-tab "Open claude.ai" branch. - overlay-delta-label.test.ts: locks in Option B from the plan — "% of session" on session budgets, "% of monthly" on credit budgets. Existing fixtures retagged with kind: 'session' across audit, integration, and unit suites. 1,548 tests green. --- tests/audit/usage-budget-audit.test.ts | 26 ++- tests/integration/agent-pipeline.test.ts | 4 + tests/integration/contracts.test.ts | 28 +++- tests/unit/conversation-store.test.ts | 76 ++++++++- tests/unit/overlay-delta-label.test.ts | 104 ++++++++++++ tests/unit/overlay-state.test.ts | 11 +- tests/unit/overlay-weekly-cap.test.ts | 36 +++- tests/unit/usage-budget-card.test.tsx | 151 +++++++++++++++++ tests/unit/usage-budget.test.ts | 189 ++++++++++++++++++--- tests/unit/usage-limits-parser.test.ts | 203 +++++++++++++++++++++++ 10 files changed, 779 insertions(+), 49 deletions(-) create mode 100644 tests/unit/overlay-delta-label.test.ts create mode 100644 tests/unit/usage-budget-card.test.tsx create mode 100644 tests/unit/usage-limits-parser.test.ts diff --git a/tests/audit/usage-budget-audit.test.ts b/tests/audit/usage-budget-audit.test.ts index d81bff2..9e1b95e 100644 --- a/tests/audit/usage-budget-audit.test.ts +++ b/tests/audit/usage-budget-audit.test.ts @@ -3,7 +3,15 @@ import { describe, test, expect } from 'vitest'; // Audit: lib/usage-budget.ts - zone classification, budget computation import { classifyZone, computeUsageBudget } from '../../lib/usage-budget'; -import type { UsageLimitsData } from '../../lib/message-types'; +import type { UsageLimitsData, UsageBudgetSession } from '../../lib/message-types'; + +// Helper: every fixture in this file is a session-tier shape, so we narrow +// once here and let the assertions read fields without re-checking `.kind`. +function computeSession(limits: UsageLimitsData, now: number): UsageBudgetSession { + const result = computeUsageBudget(limits, now); + if (result.kind !== 'session') throw new Error(`expected session variant, got ${result.kind}`); + return result; +} // ── classifyZone ─────────────────────────────────────────────────────────── @@ -41,6 +49,7 @@ describe('computeUsageBudget', () => { weeklyResetsAt: string; }> = {}): UsageLimitsData { return { + kind: 'session', fiveHour: { utilization: overrides.sessionUtil ?? 20, resetsAt: overrides.sessionResetsAt ?? '2026-04-13T14:00:00Z', @@ -49,11 +58,12 @@ describe('computeUsageBudget', () => { utilization: overrides.weeklyUtil ?? 10, resetsAt: overrides.weeklyResetsAt ?? '2026-04-16T00:00:00Z', }, - } as UsageLimitsData; + capturedAt: now, + }; } test('basic output shape', () => { - const result = computeUsageBudget(makeLimits(), now); + const result = computeSession(makeLimits(), now); expect(result.sessionPct).toBe(20); expect(result.weeklyPct).toBe(10); expect(typeof result.sessionMinutesUntilReset).toBe('number'); @@ -63,32 +73,32 @@ describe('computeUsageBudget', () => { }); test('zone is driven by the max of session and weekly', () => { - const result = computeUsageBudget(makeLimits({ sessionUtil: 30, weeklyUtil: 80 }), now); + const result = computeSession(makeLimits({ sessionUtil: 30, weeklyUtil: 80 }), now); expect(result.zone).toBe('tight'); // 80% => tight }); test('session minutes calculation', () => { // Reset at 14:00, now at 12:00 -> 120 minutes - const result = computeUsageBudget(makeLimits(), now); + const result = computeSession(makeLimits(), now); expect(result.sessionMinutesUntilReset).toBe(120); }); test('session minutes never negative', () => { // Reset time in the past - const result = computeUsageBudget(makeLimits({ + const result = computeSession(makeLimits({ sessionResetsAt: '2026-04-13T11:00:00Z', }), now); expect(result.sessionMinutesUntilReset).toBe(0); }); test('status label for comfortable zone', () => { - const result = computeUsageBudget(makeLimits({ sessionUtil: 11 }), now); + const result = computeSession(makeLimits({ sessionUtil: 11 }), now); expect(result.statusLabel).toMatch(/11% used/); expect(result.statusLabel).toMatch(/resets in/); }); test('status label for critical zone', () => { - const result = computeUsageBudget(makeLimits({ sessionUtil: 94, weeklyUtil: 94 }), now); + const result = computeSession(makeLimits({ sessionUtil: 94, weeklyUtil: 94 }), now); expect(result.statusLabel).toMatch(/94% used/); expect(result.statusLabel).toMatch(/nearly exhausted/); }); diff --git a/tests/integration/agent-pipeline.test.ts b/tests/integration/agent-pipeline.test.ts index 51fcabe..7bd13e9 100644 --- a/tests/integration/agent-pipeline.test.ts +++ b/tests/integration/agent-pipeline.test.ts @@ -400,12 +400,16 @@ describe('agent pipeline: economics -> pre-submit -> budget chain', () => { it('usage budget classifies zone from exact utilization data', () => { const limits: UsageLimitsData = { + kind: 'session', fiveHour: { utilization: 62, resetsAt: new Date(Date.now() + 3600_000).toISOString() }, sevenDay: { utilization: 28, resetsAt: new Date(Date.now() + 86400_000 * 3).toISOString() }, capturedAt: Date.now(), }; const budget = computeUsageBudget(limits, Date.now()); + // Narrow before reading session-only fields. Session is the only shape + // this fixture builds, so anything else is a regression worth blowing up on. + if (budget.kind !== 'session') throw new Error(`expected session variant, got ${budget.kind}`); expect(budget.sessionPct).toBe(62); expect(budget.weeklyPct).toBe(28); // max(62, 28) = 62: 50-74% = moderate diff --git a/tests/integration/contracts.test.ts b/tests/integration/contracts.test.ts index ebd7204..acbee90 100644 --- a/tests/integration/contracts.test.ts +++ b/tests/integration/contracts.test.ts @@ -359,9 +359,10 @@ describe('background message contracts: lifecycle messages', () => { }); describe('background message contracts: STORE_USAGE_LIMITS', () => { - it('carries all usage window fields', () => { + it('carries all usage window fields for the session variant', () => { const msg: StoreUsageLimitsMessage = { type: 'STORE_USAGE_LIMITS', + kind: 'session', organizationId: 'org-uuid', fiveHourUtilization: 34.5, fiveHourResetsAt: '2026-04-13T15:00:01.000+00:00', @@ -369,8 +370,33 @@ describe('background message contracts: STORE_USAGE_LIMITS', () => { sevenDayResetsAt: '2026-04-16T09:00:01.000+00:00', }; expect(msg.type).toBe('STORE_USAGE_LIMITS'); + if (msg.kind !== 'session') throw new Error('expected session variant'); expect(msg.fiveHourUtilization).toBe(34.5); }); + + it('carries cents and currency for the credit variant', () => { + const msg: StoreUsageLimitsMessage = { + type: 'STORE_USAGE_LIMITS', + kind: 'credit', + organizationId: 'org-uuid', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + }; + if (msg.kind !== 'credit') throw new Error('expected credit variant'); + expect(msg.monthlyLimitCents).toBe(50000); + expect(msg.currency).toBe('USD'); + }); + + it('has no payload beyond org for the unsupported variant', () => { + const msg: StoreUsageLimitsMessage = { + type: 'STORE_USAGE_LIMITS', + kind: 'unsupported', + organizationId: 'org-uuid', + }; + expect(msg.kind).toBe('unsupported'); + }); }); describe('background message contracts: GET_TOKEN_ECONOMICS', () => { diff --git a/tests/unit/conversation-store.test.ts b/tests/unit/conversation-store.test.ts index 2e8fa6d..1c9329f 100644 --- a/tests/unit/conversation-store.test.ts +++ b/tests/unit/conversation-store.test.ts @@ -778,11 +778,20 @@ describe('empty accountId throws', () => { describe('storeUsageLimits / getUsageLimits', () => { const makeLimits = (sessionPct: number, weeklyPct: number): UsageLimitsData => ({ + kind: 'session', fiveHour: { utilization: sessionPct, resetsAt: '2026-04-07T01:00:00.000Z' }, sevenDay: { utilization: weeklyPct, resetsAt: '2026-04-08T09:00:00.000Z' }, capturedAt: Date.now(), }); + // Helper: narrow without a cast. Every test in this block writes a session + // shape, so anything else coming back is a regression we want to crash on. + const expectSession = (record: UsageLimitsData | null) => { + expect(record?.kind).toBe('session'); + if (record?.kind !== 'session') throw new Error('expected session variant'); + return record; + }; + it('stores and retrieves usage limits for an account', async () => { const limits = makeLimits(11, 21); await storeUsageLimits(TEST_ORG, limits); @@ -799,9 +808,9 @@ describe('storeUsageLimits / getUsageLimits', () => { await storeUsageLimits(TEST_ORG, makeLimits(11, 21)); const updated = makeLimits(44, 55); await storeUsageLimits(TEST_ORG, updated); - const result = await getUsageLimits(TEST_ORG); - expect(result?.fiveHour.utilization).toBe(44); - expect(result?.sevenDay.utilization).toBe(55); + const session = expectSession(await getUsageLimits(TEST_ORG)); + expect(session.fiveHour.utilization).toBe(44); + expect(session.sevenDay.utilization).toBe(55); }); it('isolates data between accounts (different org IDs)', async () => { @@ -810,10 +819,10 @@ describe('storeUsageLimits / getUsageLimits', () => { await storeUsageLimits(orgA, makeLimits(10, 20)); await storeUsageLimits(orgB, makeLimits(80, 90)); - const a = await getUsageLimits(orgA); - const b = await getUsageLimits(orgB); - expect(a?.fiveHour.utilization).toBe(10); - expect(b?.fiveHour.utilization).toBe(80); + const a = expectSession(await getUsageLimits(orgA)); + const b = expectSession(await getUsageLimits(orgB)); + expect(a.fiveHour.utilization).toBe(10); + expect(b.fiveHour.utilization).toBe(80); }); it('uses the correct storage key format usageLimits:{accountId}', async () => { @@ -836,6 +845,59 @@ describe('storeUsageLimits / getUsageLimits', () => { getUsageLimits(''), ).rejects.toThrow('[LCO] accountId required for scoped storage key'); }); + + // ── Tier variants (GET-20) ──────────────────────────────────────────────── + // The store is tier-agnostic: it persists whatever discriminated-union + // shape it was handed and returns it verbatim. The parser and the agent + // own correctness; the store only owns durability. + + it('round-trips a credit-tier (Enterprise) record', async () => { + const credit: UsageLimitsData = { + kind: 'credit', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + capturedAt: 1_700_000_000_000, + }; + await storeUsageLimits(TEST_ORG, credit); + const retrieved = await getUsageLimits(TEST_ORG); + expect(retrieved).toEqual(credit); + }); + + it('round-trips an unsupported-tier record', async () => { + const unsupported: UsageLimitsData = { kind: 'unsupported', capturedAt: 1_700_000_000_000 }; + await storeUsageLimits(TEST_ORG, unsupported); + const retrieved = await getUsageLimits(TEST_ORG); + expect(retrieved).toEqual(unsupported); + }); + + // ── Legacy read shim ────────────────────────────────────────────────────── + // Records written before tier dispatch (GET-20) have no `kind` field. The + // session shape is the only one that ever made it to storage in that era, + // so we re-tag them on read and let the rest of the pipeline run normally. + + it('upgrades an untagged legacy record to the session variant on read', async () => { + // Write a pre-GET-20 record straight into the mock (no `kind` field). + mockStore._raw[`usageLimits:${TEST_ORG}`] = { + fiveHour: { utilization: 33, resetsAt: '2026-04-07T01:00:00.000Z' }, + sevenDay: { utilization: 44, resetsAt: '2026-04-08T09:00:00.000Z' }, + capturedAt: 1_600_000_000_000, + }; + const result = await getUsageLimits(TEST_ORG); + expect(result?.kind).toBe('session'); + if (result?.kind !== 'session') throw new Error('expected session'); + expect(result.fiveHour.utilization).toBe(33); + expect(result.sevenDay.utilization).toBe(44); + }); + + it('returns null for an untagged record that lacks the session shape', async () => { + // Defensive: nothing should ever write this shape, but if storage gets + // corrupted we drop it rather than guess. + mockStore._raw[`usageLimits:${TEST_ORG}`] = { something: 'else' }; + const result = await getUsageLimits(TEST_ORG); + expect(result).toBeNull(); + }); }); // ── Usage delta log (LCO-34) ────────────────────────────────────────────────── diff --git a/tests/unit/overlay-delta-label.test.ts b/tests/unit/overlay-delta-label.test.ts new file mode 100644 index 0000000..87f3eb5 --- /dev/null +++ b/tests/unit/overlay-delta-label.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +// +// Tests for the "this reply" delta label in ui/overlay.ts. The label says +// "X% of session" on Pro/Personal accounts and "X% of monthly" on Enterprise, +// because lastDeltaUtilization is tracked in tier-appropriate units +// (5-hour session window vs monthly credit pool). +// +// Locks in Option B from the GET-20 plan: the underlying number is correct +// for both tiers, but the wording would mislead an Enterprise user if it +// hard-coded "session". + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createOverlay } from '../../ui/overlay'; +import { INITIAL_STATE, applyUsageBudget } from '../../lib/overlay-state'; +import type { OverlayState } from '../../lib/overlay-state'; +import type { UsageBudgetSession, UsageBudgetCredit } from '../../lib/message-types'; + +function mountOverlay() { + const overlay = createOverlay(); + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + overlay.mount(shadow); + overlay.render(INITIAL_STATE); + return { overlay, shadow }; +} + +function getThisReplyText(shadow: ShadowRoot): string { + // The "this reply" value is the second .lco-value (first is the draft + // estimate, hidden when there is no draft); it carries the lco-accent + // modifier, which is unique on the line. + const node = shadow.querySelector('.lco-value.lco-accent'); + return node?.textContent ?? ''; +} + +const sessionBudget: UsageBudgetSession = { + kind: 'session', + sessionPct: 12, + weeklyPct: 4, + sessionMinutesUntilReset: 120, + weeklyResetLabel: 'Wed 9:00 AM', + zone: 'comfortable', + statusLabel: '12% used; resets in 2h', +}; + +const creditBudget: UsageBudgetCredit = { + kind: 'credit', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + resetLabel: 'Resets May 1', + zone: 'moderate', + statusLabel: '$304.91 of $500.00 spent', +}; + +function stateWithDelta(budget: UsageBudgetSession | UsageBudgetCredit, delta: number): OverlayState { + // applyUsageBudget gives us the right kind on state. We then lay the + // standard "reply landed" fields on top so the overlay reveals the row. + return { + ...applyUsageBudget(INITIAL_STATE, budget), + lastRequest: { inputTokens: 1200, outputTokens: 350, model: 'claude-sonnet-4-6', cost: 0.0089 }, + lastDeltaUtilization: delta, + }; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('overlay "this reply" delta label', () => { + it('says "% of session" when the budget is the session variant', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(stateWithDelta(sessionBudget, 1.7)); + const text = getThisReplyText(shadow); + expect(text).toContain('1.7% of session'); + expect(text).not.toContain('monthly'); + }); + + it('says "% of monthly" when the budget is the credit variant', () => { + // Locks in the Option B contract: Enterprise users see the right unit. + const { overlay, shadow } = mountOverlay(); + overlay.render(stateWithDelta(creditBudget, 0.4)); + const text = getThisReplyText(shadow); + expect(text).toContain('0.4% of monthly'); + expect(text).not.toContain('session'); + }); + + it('falls back to token/cost text when no delta has resolved yet', () => { + // Sanity: the label switch is gated on lastDeltaUtilization !== null. + // With no delta we still get the legacy display, regardless of tier. + const { overlay, shadow } = mountOverlay(); + overlay.render({ + ...applyUsageBudget(INITIAL_STATE, creditBudget), + lastRequest: { inputTokens: 1200, outputTokens: 350, model: 'claude-sonnet-4-6', cost: 0.0089 }, + // lastDeltaUtilization stays null — first turn, no before-snapshot. + }); + const text = getThisReplyText(shadow); + expect(text).toContain('1,200 in'); + expect(text).toContain('350 out'); + expect(text).not.toContain('monthly'); + expect(text).not.toContain('session'); + }); +}); diff --git a/tests/unit/overlay-state.test.ts b/tests/unit/overlay-state.test.ts index 0db0512..af22c1e 100644 --- a/tests/unit/overlay-state.test.ts +++ b/tests/unit/overlay-state.test.ts @@ -13,7 +13,7 @@ import { applyUsageBudget, } from '../../lib/overlay-state'; import type { OverlayState } from '../../lib/overlay-state'; -import type { TabState, UsageBudgetResult } from '../../lib/message-types'; +import type { TabState, UsageBudgetSession, BudgetZone } from '../../lib/message-types'; const MODEL = 'claude-haiku-4-5'; const TOKEN_PAYLOAD = { inputTokens: 1000, outputTokens: 200, model: MODEL }; @@ -268,8 +268,9 @@ describe('lastDeltaUtilization spread semantics', () => { // ── applyUsageBudget ────────────────────────────────────────────────────────── -function makeBudget(weeklyPct: number, zone: UsageBudgetResult['zone'] = 'comfortable'): UsageBudgetResult { +function makeBudget(weeklyPct: number, zone: BudgetZone = 'comfortable'): UsageBudgetSession { return { + kind: 'session', sessionPct: 10, weeklyPct, sessionMinutesUntilReset: 120, @@ -291,7 +292,11 @@ describe('applyUsageBudget', () => { const second = makeBudget(85, 'tight'); const state = applyUsageBudget(INITIAL_STATE, first); const next = applyUsageBudget(state, second); - expect(next.usageBudget?.weeklyPct).toBe(85); + // makeBudget always returns a session variant; narrow before reading + // session-only fields rather than reaching into the union with a cast. + expect(next.usageBudget?.kind).toBe('session'); + if (next.usageBudget?.kind !== 'session') throw new Error('expected session'); + expect(next.usageBudget.weeklyPct).toBe(85); }); it('does not mutate other fields', () => { diff --git a/tests/unit/overlay-weekly-cap.test.ts b/tests/unit/overlay-weekly-cap.test.ts index 5130bcd..1ac7826 100644 --- a/tests/unit/overlay-weekly-cap.test.ts +++ b/tests/unit/overlay-weekly-cap.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createOverlay } from '../../ui/overlay'; import { INITIAL_STATE, applyUsageBudget } from '../../lib/overlay-state'; import { computeUsageBudget, classifyZone } from '../../lib/usage-budget'; -import type { UsageLimitsData, UsageBudgetResult } from '../../lib/message-types'; +import type { UsageLimitsData, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../lib/message-types'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -17,6 +17,7 @@ const NOW = new Date('2026-04-07T00:00:00.000Z').getTime(); function makeLimits(sessionPct: number, weeklyPct: number): UsageLimitsData { return { + kind: 'session', fiveHour: { utilization: sessionPct, resetsAt: new Date(NOW + 60 * 60000).toISOString(), @@ -29,8 +30,9 @@ function makeLimits(sessionPct: number, weeklyPct: number): UsageLimitsData { }; } -function makeBudget(weeklyPct: number, zone: UsageBudgetResult['zone'] = 'comfortable'): UsageBudgetResult { +function makeBudget(weeklyPct: number, zone: BudgetZone = 'comfortable'): UsageBudgetSession { return { + kind: 'session', sessionPct: 10, weeklyPct, sessionMinutesUntilReset: 60, @@ -147,7 +149,8 @@ describe('weekly bar zone class', () => { // on weekly alone, matching the UsageBudgetCard.tsx convention. const { overlay, shadow } = mountOverlay(); // sessionPct=95 (critical overall) but weeklyPct=30 (comfortable). - const budget: UsageBudgetResult = { + const budget: UsageBudgetSession = { + kind: 'session', sessionPct: 95, weeklyPct: 30, sessionMinutesUntilReset: 5, @@ -160,6 +163,28 @@ describe('weekly bar zone class', () => { expect(fill.classList.contains('lco-bar-fill--comfortable')).toBe(true); expect(fill.classList.contains('lco-bar-fill--critical')).toBe(false); }); + + // ── Tier-variant gating (GET-20) ────────────────────────────────────────── + // The weekly bar belongs only to the session tier. Credit (Enterprise) + // budgets must keep it hidden; unsupported budgets never reach overlay + // state at all (the content script gates them out, and the type system + // rejects them at applyUsageBudget), so they cannot be tested here. + + it('is hidden when the budget is the credit (Enterprise) variant', () => { + const { overlay, shadow } = mountOverlay(); + const credit: UsageBudgetCredit = { + kind: 'credit', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + resetLabel: 'Resets May 1', + zone: 'moderate', + statusLabel: '$304.91 of $500.00 spent', + }; + overlay.render(applyUsageBudget(INITIAL_STATE, credit)); + expect(getWeeklyRow(shadow)!.style.display).toBe('none'); + }); }); // ── Label text ──────────────────────────────────────────────────────────────── @@ -184,18 +209,21 @@ describe('overlay/side-panel weeklyPct invariant', () => { it('both surfaces read weeklyPct from the same computeUsageBudget call path', () => { const limits = makeLimits(12, 71); const budget = computeUsageBudget(limits, NOW); + if (budget.kind !== 'session') throw new Error('expected session'); // weeklyPct is a direct pass-through of sevenDay.utilization — no transformation. expect(budget.weeklyPct).toBe(71); // The overlay will call applyUsageBudget(state, budget), which stores this value. // The side panel calls computeUsageBudget(limits, Date.now()) in useDashboardData.ts. // Both derive from the same source data; the weeklyPct is always equal. const state = applyUsageBudget(INITIAL_STATE, budget); - expect(state.usageBudget?.weeklyPct).toBe(budget.weeklyPct); + if (state.usageBudget?.kind !== 'session') throw new Error('expected session'); + expect(state.usageBudget.weeklyPct).toBe(budget.weeklyPct); }); it('zone classification agrees for the same weeklyPct', () => { const limits = makeLimits(10, 71); const budget = computeUsageBudget(limits, NOW); + if (budget.kind !== 'session') throw new Error('expected session'); // The overlay bar uses classifyZone(weeklyPct) directly. // The side panel UsageBudgetCard uses classifyZone(weeklyPct) for the weekly fill. // Both reference the same exported function with the same input. diff --git a/tests/unit/usage-budget-card.test.tsx b/tests/unit/usage-budget-card.test.tsx new file mode 100644 index 0000000..0b5622f --- /dev/null +++ b/tests/unit/usage-budget-card.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +// +// Render tests for the side-panel Usage Budget card. These cover the three +// branches that satisfy GET-20 acceptance criteria directly: +// +// AC #1 — Enterprise (credit) tab shows monthly spend bar +// AC #2 — Pro/Personal (session) tab keeps the existing two-bar layout +// AC #3 — Teams / unrecognized tier shows "Saar can't read..." empty state +// +// The agent layer (lib/usage-budget.ts) tests verify the math, formatting, +// and zone classification. This file verifies the JSX actually renders the +// fields the agent produces — a small typo in the card would otherwise +// regress an acceptance criterion silently. + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import UsageBudgetCard from '../../entrypoints/sidepanel/components/UsageBudgetCard'; +import type { UsageBudgetSession, UsageBudgetCredit, UsageBudgetResult } from '../../lib/message-types'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function sessionBudget(overrides: Partial = {}): UsageBudgetSession { + return { + kind: 'session', + sessionPct: 22, + weeklyPct: 14, + sessionMinutesUntilReset: 73, + weeklyResetLabel: 'Wed 9:00 AM', + zone: 'comfortable', + statusLabel: '22% used; resets in 1h 13m', + ...overrides, + }; +} + +// Anchored on Devanshu's Northeastern Enterprise account (2026-04-23). +function creditBudget(overrides: Partial = {}): UsageBudgetCredit { + return { + kind: 'credit', + monthlyLimitCents: 50000, + usedCents: 30491, + utilizationPct: 60.982, + currency: 'USD', + resetLabel: 'Resets May 1', + zone: 'moderate', + statusLabel: '$304.91 of $500.00 spent', + ...overrides, + }; +} + +// ── Empty states ───────────────────────────────────────────────────────────── + +describe('UsageBudgetCard — empty states', () => { + it('prompts the user to open claude.ai when off-tab and budget is null', () => { + render(); + expect(screen.getByText('Open claude.ai to load usage data')).toBeTruthy(); + }); + + it('shows the "account type not supported" message when on-tab but budget is null', () => { + // null on a Claude tab means the fetch failed before the parser could + // classify anything; we still tell the user we cannot read their account + // rather than nudge them somewhere they already are. + render(); + expect(screen.getByText(/can't read usage on this account type/i)).toBeTruthy(); + }); + + it('shows the "account type not supported" message on the unsupported variant', () => { + const unsupported: UsageBudgetResult = { kind: 'unsupported' }; + render(); + expect(screen.getByText(/can't read usage on this account type/i)).toBeTruthy(); + }); +}); + +// ── Session variant (Pro / Personal / Max) ─────────────────────────────────── +// AC #2: Pro/Personal accounts must see the existing session + weekly layout +// with no regression. + +describe('UsageBudgetCard — session variant', () => { + it('renders the session and weekly bars side by side', () => { + render(); + expect(screen.getByText('Session')).toBeTruthy(); + expect(screen.getByText('Weekly')).toBeTruthy(); + }); + + it('shows the agent-provided status label as the primary line', () => { + const budget = sessionBudget({ statusLabel: '22% used; resets in 1h 13m' }); + render(); + expect(screen.getByText('22% used; resets in 1h 13m')).toBeTruthy(); + }); + + it('shows the rounded session and weekly percentages', () => { + const budget = sessionBudget({ sessionPct: 22, weeklyPct: 14 }); + render(); + expect(screen.getByText('22%')).toBeTruthy(); + expect(screen.getByText('14%')).toBeTruthy(); + }); + + it('exposes both reset lines (session countdown + weekly label)', () => { + const budget = sessionBudget({ sessionMinutesUntilReset: 73, weeklyResetLabel: 'Wed 9:00 AM' }); + render(); + // "73 min" formats to "1h 13m" inside the card. + expect(screen.getByText(/Session resets in 1h 13m/)).toBeTruthy(); + expect(screen.getByText(/Weekly resets Wed 9:00 AM/)).toBeTruthy(); + }); + + it('does not render the Enterprise pill on the session variant', () => { + render(); + expect(screen.queryByText('Enterprise')).toBeNull(); + }); +}); + +// ── Credit variant (Enterprise) ────────────────────────────────────────────── +// AC #1: Enterprise must see the monthly spend bar with $X of $Y · Resets {date} +// and the correct utilization %, not the "Open claude.ai" prompt. + +describe('UsageBudgetCard — credit variant', () => { + it('shows the "Enterprise" tier pill in the header', () => { + render(); + expect(screen.getByText('Enterprise')).toBeTruthy(); + }); + + it('shows the agent-provided spend status label', () => { + render(); + expect(screen.getByText('$304.91 of $500.00 spent')).toBeTruthy(); + }); + + it('shows a single Monthly bar (no Session, no Weekly)', () => { + render(); + expect(screen.getByText('Monthly')).toBeTruthy(); + expect(screen.queryByText('Session')).toBeNull(); + expect(screen.queryByText('Weekly')).toBeNull(); + }); + + it('renders the rounded utilization percentage', () => { + // 60.982 rounds to 61. + render(); + expect(screen.getByText('61%')).toBeTruthy(); + }); + + it('shows the agent-provided reset label', () => { + render(); + expect(screen.getByText('Resets May 1')).toBeTruthy(); + }); + + it('does not show the "Open claude.ai" prompt when budget is present', () => { + // Direct regression guard for the original GET-20 bug: Enterprise users + // saw the empty-state placeholder instead of their actual spend. + render(); + expect(screen.queryByText(/Open claude.ai/)).toBeNull(); + }); +}); diff --git a/tests/unit/usage-budget.test.ts b/tests/unit/usage-budget.test.ts index 7b1b383..2bafb75 100644 --- a/tests/unit/usage-budget.test.ts +++ b/tests/unit/usage-budget.test.ts @@ -15,8 +15,8 @@ // - Handles past resetAt timestamps gracefully (shows 0 min) import { describe, it, expect } from 'vitest'; -import { computeUsageBudget } from '../../lib/usage-budget'; -import type { UsageLimitsData } from '../../lib/message-types'; +import { computeUsageBudget, getTrackedUtilization } from '../../lib/usage-budget'; +import type { UsageLimitsData, UsageBudgetSession, UsageBudgetCredit } from '../../lib/message-types'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -29,47 +29,78 @@ function makeReset(minutesFromNow: number): string { function makeLimits(sessionPct: number, weeklyPct: number, sessionMinutes = 60): UsageLimitsData { const weeklyResetsAt = new Date('2026-04-08T09:00:00.000Z').toISOString(); return { + kind: 'session', fiveHour: { utilization: sessionPct, resetsAt: makeReset(sessionMinutes) }, sevenDay: { utilization: weeklyPct, resetsAt: weeklyResetsAt }, capturedAt: NOW, }; } +function makeCreditLimits(opts: { + monthlyLimitCents?: number; + usedCents?: number; + utilizationPct?: number; + currency?: string; +} = {}): UsageLimitsData { + return { + kind: 'credit', + monthlyLimitCents: opts.monthlyLimitCents ?? 50000, + usedCents: opts.usedCents ?? 30491, + utilizationPct: opts.utilizationPct ?? 60.982, + currency: opts.currency ?? 'USD', + capturedAt: NOW, + }; +} + +// Helpers: every test in the existing session-tier suite only needs a session +// variant back. Narrow once and let the assertions stay readable. +function computeSession(limits: UsageLimitsData, now: number): UsageBudgetSession { + const result = computeUsageBudget(limits, now); + if (result.kind !== 'session') throw new Error(`expected session, got ${result.kind}`); + return result; +} + +function computeCredit(limits: UsageLimitsData, now: number): UsageBudgetCredit { + const result = computeUsageBudget(limits, now); + if (result.kind !== 'credit') throw new Error(`expected credit, got ${result.kind}`); + return result; +} + // ── Zone classification ─────────────────────────────────────────────────────── describe('computeUsageBudget -- zone classification', () => { it('classifies 49% as comfortable', () => { - const result = computeUsageBudget(makeLimits(49, 0), NOW); + const result = computeSession(makeLimits(49, 0), NOW); expect(result.zone).toBe('comfortable'); }); it('classifies 50% as moderate', () => { - const result = computeUsageBudget(makeLimits(50, 0), NOW); + const result = computeSession(makeLimits(50, 0), NOW); expect(result.zone).toBe('moderate'); }); it('classifies 74% as moderate', () => { - const result = computeUsageBudget(makeLimits(74, 0), NOW); + const result = computeSession(makeLimits(74, 0), NOW); expect(result.zone).toBe('moderate'); }); it('classifies 75% as tight', () => { - const result = computeUsageBudget(makeLimits(75, 0), NOW); + const result = computeSession(makeLimits(75, 0), NOW); expect(result.zone).toBe('tight'); }); it('classifies 89% as tight', () => { - const result = computeUsageBudget(makeLimits(89, 0), NOW); + const result = computeSession(makeLimits(89, 0), NOW); expect(result.zone).toBe('tight'); }); it('classifies 90% as critical', () => { - const result = computeUsageBudget(makeLimits(90, 0), NOW); + const result = computeSession(makeLimits(90, 0), NOW); expect(result.zone).toBe('critical'); }); it('classifies 100% as critical', () => { - const result = computeUsageBudget(makeLimits(100, 0), NOW); + const result = computeSession(makeLimits(100, 0), NOW); expect(result.zone).toBe('critical'); }); }); @@ -80,18 +111,18 @@ describe('computeUsageBudget -- zone based on max utilization', () => { it('uses weekly pct when weekly is higher than session', () => { // Session at 20% (comfortable), weekly at 85% (tight). // Zone should be tight, not comfortable. - const result = computeUsageBudget(makeLimits(20, 85), NOW); + const result = computeSession(makeLimits(20, 85), NOW); expect(result.zone).toBe('tight'); }); it('uses session pct when session is higher than weekly', () => { // Session at 92% (critical), weekly at 30% (comfortable). - const result = computeUsageBudget(makeLimits(92, 30), NOW); + const result = computeSession(makeLimits(92, 30), NOW); expect(result.zone).toBe('critical'); }); it('returns correct individual pcts regardless of zone driver', () => { - const result = computeUsageBudget(makeLimits(20, 85), NOW); + const result = computeSession(makeLimits(20, 85), NOW); expect(result.sessionPct).toBe(20); expect(result.weeklyPct).toBe(85); }); @@ -101,32 +132,34 @@ describe('computeUsageBudget -- zone based on max utilization', () => { describe('computeUsageBudget -- sessionMinutesUntilReset', () => { it('returns exact minutes until reset', () => { - const result = computeUsageBudget(makeLimits(11, 0, 53), NOW); + const result = computeSession(makeLimits(11, 0, 53), NOW); expect(result.sessionMinutesUntilReset).toBe(53); }); it('floors at 0 when reset is in the past', () => { const pastLimits: UsageLimitsData = { + kind: 'session', fiveHour: { utilization: 11, resetsAt: new Date(NOW - 60000).toISOString() }, sevenDay: { utilization: 0, resetsAt: new Date('2026-04-08T09:00:00.000Z').toISOString() }, capturedAt: NOW, }; - const result = computeUsageBudget(pastLimits, NOW); + const result = computeSession(pastLimits, NOW); expect(result.sessionMinutesUntilReset).toBe(0); }); it('handles reset exactly at now as 0', () => { const nowLimits: UsageLimitsData = { + kind: 'session', fiveHour: { utilization: 50, resetsAt: new Date(NOW).toISOString() }, sevenDay: { utilization: 0, resetsAt: new Date('2026-04-08T09:00:00.000Z').toISOString() }, capturedAt: NOW, }; - const result = computeUsageBudget(nowLimits, NOW); + const result = computeSession(nowLimits, NOW); expect(result.sessionMinutesUntilReset).toBe(0); }); it('returns correct countdown for 1h 12m (72 minutes)', () => { - const result = computeUsageBudget(makeLimits(30, 0, 72), NOW); + const result = computeSession(makeLimits(30, 0, 72), NOW); expect(result.sessionMinutesUntilReset).toBe(72); }); }); @@ -136,14 +169,14 @@ describe('computeUsageBudget -- sessionMinutesUntilReset', () => { describe('computeUsageBudget -- weeklyResetLabel', () => { it('formats the weekly reset as a short day + time string', () => { // 2026-04-08 is a Wednesday. - const result = computeUsageBudget(makeLimits(11, 21), NOW); + const result = computeSession(makeLimits(11, 21), NOW); // The label should contain "Wed" (the day) and time info. // Intl formatting varies by locale, so we test the pattern not the exact string. expect(result.weeklyResetLabel).toMatch(/Wed/i); }); it('produces a non-empty string', () => { - const result = computeUsageBudget(makeLimits(11, 21), NOW); + const result = computeSession(makeLimits(11, 21), NOW); expect(result.weeklyResetLabel.length).toBeGreaterThan(3); }); }); @@ -152,34 +185,34 @@ describe('computeUsageBudget -- weeklyResetLabel', () => { describe('computeUsageBudget -- statusLabel', () => { it('contains the session utilization percentage', () => { - const result = computeUsageBudget(makeLimits(11, 0, 53), NOW); + const result = computeSession(makeLimits(11, 0, 53), NOW); expect(result.statusLabel).toContain('11%'); }); it('contains reset countdown for comfortable zone', () => { - const result = computeUsageBudget(makeLimits(20, 0, 53), NOW); + const result = computeSession(makeLimits(20, 0, 53), NOW); expect(result.statusLabel).toContain('resets in'); expect(result.statusLabel).toContain('53 min'); }); it('contains reset countdown for tight zone', () => { - const result = computeUsageBudget(makeLimits(80, 0, 23), NOW); + const result = computeSession(makeLimits(80, 0, 23), NOW); expect(result.statusLabel).toContain('23 min'); }); it('uses urgency language for critical zone', () => { - const result = computeUsageBudget(makeLimits(94, 0, 5), NOW); + const result = computeSession(makeLimits(94, 0, 5), NOW); expect(result.statusLabel).toContain('exhausted'); }); it('formats hours correctly in countdown', () => { - const result = computeUsageBudget(makeLimits(30, 0, 72), NOW); + const result = computeSession(makeLimits(30, 0, 72), NOW); // 72 minutes = "1h 12m" expect(result.statusLabel).toContain('1h 12m'); }); it('shows exact hours when minutes are zero', () => { - const result = computeUsageBudget(makeLimits(30, 0, 60), NOW); + const result = computeSession(makeLimits(30, 0, 60), NOW); // 60 minutes = "1h" expect(result.statusLabel).toContain('1h'); expect(result.statusLabel).not.toContain('0m'); @@ -190,7 +223,7 @@ describe('computeUsageBudget -- statusLabel', () => { describe('computeUsageBudget -- output structure', () => { it('returns all required fields', () => { - const result = computeUsageBudget(makeLimits(11, 21), NOW); + const result = computeSession(makeLimits(11, 21), NOW); expect(typeof result.sessionPct).toBe('number'); expect(typeof result.weeklyPct).toBe('number'); expect(typeof result.sessionMinutesUntilReset).toBe('number'); @@ -200,8 +233,112 @@ describe('computeUsageBudget -- output structure', () => { }); it('passes through utilization values unchanged', () => { - const result = computeUsageBudget(makeLimits(11, 21), NOW); + const result = computeSession(makeLimits(11, 21), NOW); expect(result.sessionPct).toBe(11); expect(result.weeklyPct).toBe(21); }); }); + +// ── Credit variant (Enterprise) ─────────────────────────────────────────────── +// Anchored on the fixture from Devanshu's Northeastern Enterprise account +// (2026-04-23): $500.00 monthly cap, $304.91 spent, 60.982% utilization, USD. + +describe('computeUsageBudget -- credit variant', () => { + it('discriminates as credit', () => { + const result = computeUsageBudget(makeCreditLimits(), NOW); + expect(result.kind).toBe('credit'); + }); + + it('passes cents and currency through unchanged', () => { + const result = computeCredit(makeCreditLimits(), NOW); + expect(result.monthlyLimitCents).toBe(50000); + expect(result.usedCents).toBe(30491); + expect(result.utilizationPct).toBeCloseTo(60.982, 3); + expect(result.currency).toBe('USD'); + }); + + it('formats the status label as currency-aware "$X of $Y spent"', () => { + const result = computeCredit(makeCreditLimits(), NOW); + // Intl-formatted USD on en-US: "$304.91 of $500.00 spent" + expect(result.statusLabel).toContain('304.91'); + expect(result.statusLabel).toContain('500.00'); + expect(result.statusLabel).toContain('spent'); + }); + + it('renders a reset label of "Resets {Mon DD}" using next-month rollover', () => { + // NOW is 2026-04-07 → next month resets on May 1. + const result = computeCredit(makeCreditLimits(), NOW); + expect(result.resetLabel.startsWith('Resets ')).toBe(true); + expect(result.resetLabel).toMatch(/May/); + expect(result.resetLabel).toMatch(/\b1\b/); + }); + + it('rolls over from December to the following January', () => { + const dec = new Date('2026-12-15T00:00:00.000Z').getTime(); + const result = computeCredit(makeCreditLimits(), dec); + expect(result.resetLabel).toMatch(/Jan/); + expect(result.resetLabel).toMatch(/\b1\b/); + }); + + it('classifies zone from utilizationPct alone (49% → comfortable)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 49 }), NOW); + expect(result.zone).toBe('comfortable'); + }); + + it('classifies zone from utilizationPct alone (50% → moderate)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 50 }), NOW); + expect(result.zone).toBe('moderate'); + }); + + it('classifies zone from utilizationPct alone (74% → moderate)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 74 }), NOW); + expect(result.zone).toBe('moderate'); + }); + + it('classifies zone from utilizationPct alone (75% → tight)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 75 }), NOW); + expect(result.zone).toBe('tight'); + }); + + it('classifies zone from utilizationPct alone (89% → tight)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 89 }), NOW); + expect(result.zone).toBe('tight'); + }); + + it('classifies zone from utilizationPct alone (90% → critical)', () => { + const result = computeCredit(makeCreditLimits({ utilizationPct: 90 }), NOW); + expect(result.zone).toBe('critical'); + }); + + it('formats unknown currency codes as a "CODE 304.91" fallback', () => { + // Intl rejects nonsense currency codes. The agent must not crash; it + // falls through to a portable form so the card never shows NaN. + const result = computeCredit(makeCreditLimits({ currency: 'XYZ' }), NOW); + expect(result.currency).toBe('XYZ'); + expect(result.statusLabel).toContain('XYZ'); + expect(result.statusLabel).toContain('304.91'); + }); +}); + +// ── Unsupported variant ────────────────────────────────────────────────────── + +describe('computeUsageBudget -- unsupported variant', () => { + it('passes through as a kinded result with no fields', () => { + const result = computeUsageBudget({ kind: 'unsupported', capturedAt: NOW }, NOW); + expect(result.kind).toBe('unsupported'); + }); +}); + +// ── getTrackedUtilization ──────────────────────────────────────────────────── + +describe('getTrackedUtilization', () => { + it('returns sessionPct on the session variant', () => { + const session = computeSession(makeLimits(37, 12), NOW); + expect(getTrackedUtilization(session)).toBe(37); + }); + + it('returns utilizationPct on the credit variant', () => { + const credit = computeCredit(makeCreditLimits({ utilizationPct: 60.982 }), NOW); + expect(getTrackedUtilization(credit)).toBeCloseTo(60.982, 3); + }); +}); diff --git a/tests/unit/usage-limits-parser.test.ts b/tests/unit/usage-limits-parser.test.ts new file mode 100644 index 0000000..84e1dec --- /dev/null +++ b/tests/unit/usage-limits-parser.test.ts @@ -0,0 +1,203 @@ +// tests/unit/usage-limits-parser.test.ts +// Unit tests for parseUsageResponse — the only place that classifies the +// raw /api/organizations/{orgId}/usage payload into a tier variant. +// +// The agents downstream all branch on `kind`, so any drift here produces +// silently wrong UI. The matrix below covers each branch + the boundary +// between "unparseable input we drop" and "200 with neither shape, surface +// as unsupported". +// +// Fixtures anchor on real responses captured 2026-04-23: +// - session: a Pro account (utilization + resets_at on both windows) +// - credit: Devanshu's Northeastern Enterprise account +// (extra_usage with $500 cap / $304.91 spent / 60.982%) + +import { describe, it, expect } from 'vitest'; +import { parseUsageResponse } from '../../lib/usage-limits-parser'; + +// ── Session shape (Pro / Personal / Max / Team) ────────────────────────────── + +describe('parseUsageResponse -- session shape', () => { + const sessionResponse = { + five_hour: { + utilization: 33.5, + resets_at: '2026-04-07T05:00:00.000+00:00', + }, + seven_day: { + utilization: 12.1, + resets_at: '2026-04-13T00:00:00.000+00:00', + }, + }; + + it('discriminates as session', () => { + const result = parseUsageResponse(sessionResponse); + expect(result?.kind).toBe('session'); + }); + + it('passes the two windows through unchanged', () => { + const result = parseUsageResponse(sessionResponse); + if (result?.kind !== 'session') throw new Error('expected session'); + expect(result.fiveHour.utilization).toBe(33.5); + expect(result.fiveHour.resetsAt).toBe('2026-04-07T05:00:00.000+00:00'); + expect(result.sevenDay.utilization).toBe(12.1); + expect(result.sevenDay.resetsAt).toBe('2026-04-13T00:00:00.000+00:00'); + }); + + it('stamps capturedAt at parse time', () => { + const before = Date.now(); + const result = parseUsageResponse(sessionResponse); + const after = Date.now(); + if (result?.kind !== 'session') throw new Error('expected session'); + expect(result.capturedAt).toBeGreaterThanOrEqual(before); + expect(result.capturedAt).toBeLessThanOrEqual(after); + }); + + it('falls through to unsupported when only one window is populated', () => { + // A half-populated session response is not a session response; we + // would rather show "unsupported" than render half a card. + const result = parseUsageResponse({ + five_hour: { utilization: 10, resets_at: '2026-04-07T05:00:00.000Z' }, + seven_day: null, + }); + expect(result?.kind).toBe('unsupported'); + }); + + it('falls through to unsupported when utilization is the wrong type', () => { + const result = parseUsageResponse({ + five_hour: { utilization: '33', resets_at: '2026-04-07T05:00:00.000Z' }, + seven_day: { utilization: 12, resets_at: '2026-04-13T00:00:00.000Z' }, + }); + expect(result?.kind).toBe('unsupported'); + }); +}); + +// ── Credit shape (Enterprise) ──────────────────────────────────────────────── + +describe('parseUsageResponse -- credit shape', () => { + // Verbatim fixture from Devanshu's Northeastern Enterprise account. + const enterpriseResponse = { + five_hour: null, + seven_day: null, + extra_usage: { + is_enabled: true, + monthly_limit: 50000, + used_credits: 30491.0, + utilization: 60.982, + currency: 'USD', + }, + }; + + it('discriminates as credit', () => { + const result = parseUsageResponse(enterpriseResponse); + expect(result?.kind).toBe('credit'); + }); + + it('passes the cents fields through unchanged', () => { + const result = parseUsageResponse(enterpriseResponse); + if (result?.kind !== 'credit') throw new Error('expected credit'); + expect(result.monthlyLimitCents).toBe(50000); + expect(result.usedCents).toBe(30491); + expect(result.utilizationPct).toBeCloseTo(60.982, 3); + expect(result.currency).toBe('USD'); + }); + + it('falls through to unsupported when extra_usage.is_enabled is false', () => { + const result = parseUsageResponse({ + extra_usage: { + is_enabled: false, + monthly_limit: 50000, + used_credits: 100, + utilization: 0.2, + currency: 'USD', + }, + }); + expect(result?.kind).toBe('unsupported'); + }); + + it('falls through to unsupported when a numeric field is missing', () => { + // Half-credit shape: rather than render an incomplete bar we surface + // the unsupported state and stay honest. + const result = parseUsageResponse({ + extra_usage: { + is_enabled: true, + monthly_limit: 50000, + used_credits: 100, + // utilization missing + currency: 'USD', + }, + }); + expect(result?.kind).toBe('unsupported'); + }); +}); + +// ── Unsupported (200, but neither shape) ───────────────────────────────────── + +describe('parseUsageResponse -- unsupported', () => { + it('returns unsupported for an empty object', () => { + const result = parseUsageResponse({}); + expect(result?.kind).toBe('unsupported'); + }); + + it('returns unsupported when both top-level windows are explicit null and credit is disabled', () => { + // Some Teams accounts return this shape: nothing actionable to render. + const result = parseUsageResponse({ + five_hour: null, + seven_day: null, + extra_usage: { is_enabled: false }, + }); + expect(result?.kind).toBe('unsupported'); + }); + + it('stamps capturedAt on unsupported variants too', () => { + const before = Date.now(); + const result = parseUsageResponse({}); + const after = Date.now(); + if (result?.kind !== 'unsupported') throw new Error('expected unsupported'); + expect(result.capturedAt).toBeGreaterThanOrEqual(before); + expect(result.capturedAt).toBeLessThanOrEqual(after); + }); +}); + +// ── Null pass-through (unparseable) ────────────────────────────────────────── +// `null` here means "we cannot inspect the body at all"; the caller keeps any +// previous render in place rather than flipping to an empty state on a single +// transient bad response. + +describe('parseUsageResponse -- unparseable input', () => { + it('returns null for null', () => { + expect(parseUsageResponse(null)).toBeNull(); + }); + + it('returns null for a bare string', () => { + expect(parseUsageResponse('hello')).toBeNull(); + }); + + it('returns null for a bare number', () => { + expect(parseUsageResponse(42)).toBeNull(); + }); + + it('returns null for an array (not the object shape we expect)', () => { + expect(parseUsageResponse([])).toBeNull(); + }); +}); + +// ── Dispatch priority ──────────────────────────────────────────────────────── + +describe('parseUsageResponse -- dispatch priority', () => { + it('prefers session when both shapes happen to be populated', () => { + // We have not seen this combo in the wild, but the function must be + // total. Session is the long-standing default; we lock in that choice. + const result = parseUsageResponse({ + five_hour: { utilization: 10, resets_at: '2026-04-07T05:00:00.000Z' }, + seven_day: { utilization: 5, resets_at: '2026-04-13T00:00:00.000Z' }, + extra_usage: { + is_enabled: true, + monthly_limit: 50000, + used_credits: 100, + utilization: 0.2, + currency: 'USD', + }, + }); + expect(result?.kind).toBe('session'); + }); +});