diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index a923971d09..f51c0ad510 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/usage-reports"; import { installLoginUsage, + expectLoginUsageFreshness, installCodexWindowUsage, expectCodexReportedWindows, refreshLoginUsage, @@ -588,6 +589,69 @@ async function expectLoginErrors(page: Page, lines: string[]) { async function expectLoginUsageOnly(page: Page, percentage: string) { const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible(); await expect(card.getByTestId("usage-login-error")).toHaveCount(0); } + +for (const compact of [false, true]) { + test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({ + page, + }) => { + const fixture = await installLoginUsage({ status: "error", error: "Initial failure" }); + try { + await page.setViewportSize( + compact ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + await gotoAppShell(page); + if (compact) await openCompactSidebar(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, ["Claude: Initial failure"]); + await expectLoginUsageFreshness(page, "No successful update", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }], + }); + // Move only the browser clock; real daemon fetches continue using the host clock. + await page.clock.setFixedTime(new Date(Date.now() + 3_900_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageFreshness(page, "Updated 1h ago", compact); + + await fixture.setReport({ status: "error", error: "Rate limited" }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "unavailable", + problem: { kind: "no_quota", detail: "No active quota" }, + }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: No active quota"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }], + }); + await page.clock.setFixedTime(new Date(Date.now() + 150_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "43%"); + await expect(page.getByText("42%", { exact: true })).toHaveCount(0); + await expectLoginUsageFreshness(page, "Updated 2m ago", compact); + } finally { + await fixture.cleanup(); + } + }); +} + +async function expectRetainedLoginUsage(page: Page, message: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveText(message); + await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 6129f0259e..25ee01d5d5 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -135,3 +135,14 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o ); } } + +export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) { + const card = page.getByTestId("usage-report-login-journey:account"); + if (compact) { + await expect(card.getByTestId("usage-freshness")).toHaveText(text); + return; + } + await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text); + await page.mouse.move(0, 0); +} diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 751193eec0..38b2f0804a 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -41,11 +41,17 @@ function fillToneStyle(tone: UsageTone) { } } -export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { +export function UsageBalanceBar({ + balance, + retained = false, +}: { + balance: UsageBalance; + retained?: boolean; +}) { const { i18n } = useTranslation(); const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; - const resetLabel = formatResetLabel(balance.resetsAt); + const resetLabel = formatResetLabel(balance.resetsAt, retained); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index a1936e0f97..562f3805a1 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -16,7 +16,7 @@ import { useCompactTimeAgo } from "@/hooks/use-time-ago"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; @@ -27,31 +27,6 @@ function statusText(report: UsageReport): string | null { return report.status === "error" ? "Error" : "Unavailable"; } -function reportContent(report: UsageReport) { - if (report.status === "available") - return { - windows: report.windows, - balances: report.balances ?? [], - details: report.details ?? [], - message: undefined, - }; - const message = - report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; - return { windows: [], balances: [], details: [], message }; -} - -function reportMessages(entry: UsageReportEntry): string[] { - if (entry.report.status === "available") return []; - if (entry.loginErrors) - return entry.loginErrors.map( - (login) => `${login.harness}: ${reportContent(login.report).message}`, - ); - // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. - return [ - entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), - ]; -} - const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -81,8 +56,8 @@ export function UsageCard({ const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details } = reportContent(usage); - const messages = reportMessages(entry); + const { windows, balances, details, planLabel, successfulAt, retained, messages } = + resolveUsageReport(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -104,9 +79,7 @@ export function UsageCard({ {entry.sourceLabel} - {usage.status === "available" && usage.planLabel ? ( - - ) : null} + {planLabel ? : null} {status ? ( @@ -117,7 +90,8 @@ export function UsageCard({ {refreshable ? ( ))} {balances.map((balance) => ( - + ))} ) : null} @@ -170,7 +145,8 @@ export function UsageCard({ {showsFreshnessInline ? ( @@ -192,7 +168,9 @@ function CardWindowBar({ window, display, pinnable, + retained, }: { + retained: boolean; entry: UsageReportEntry; window: UsageWindow; display: UsageDisplay; @@ -207,6 +185,7 @@ function CardWindowBar({ return ( void; compact: boolean; @@ -236,12 +217,13 @@ function UsageRefreshButton({ const freshness = useMemo( () => ( ), - [fetchedAt], + [successfulAt, retained], ); return ( `, not the card. */ function UsageFreshness({ - fetchedAt, + successfulAt, + retained, style, testID, }: { - fetchedAt: string; + successfulAt: string | null; + retained: boolean; style: StyleProp; testID: string; }) { - const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null); return ( - {formatUsageFreshness(elapsed)} + {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} ); } diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 88fe227ae6..22529c8d71 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatAmount } from "./format"; +import { formatAmount, formatResetLabel } from "./format"; describe("formatAmount", () => { it("groups thousands in the app's language", () => { @@ -13,3 +13,10 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); }); + +it("retained reset times that have passed do not claim the quota is resetting", () => { + const past = "2000-01-01T00:00:00.000Z"; + expect(formatResetLabel(past, true)).toBe("Reset time passed"); + expect(formatResetLabel(past)).toBe("resetting now"); + expect(formatResetLabel(null, true)).toBeNull(); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5adef2154d..e13a550e7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -27,10 +27,11 @@ function relativeDuration(iso: string): string | null { return `${diffMinutes}m`; } -export function formatResetLabel(iso: string | null | undefined): string | null { +export function formatResetLabel(iso: string | null | undefined, retained = false): string | null { if (!iso) return null; const rel = relativeDuration(iso); if (!rel) return null; + if (rel === "now" && retained) return "Reset time passed"; return rel === "now" ? "resetting now" : `resets ${rel}`; } diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a2c493619d..13da42a6eb 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + resolveUsageReport, displayPercent, formatUsageFreshness, resolveAgentUsageView, @@ -325,3 +326,72 @@ describe("resolveAgentUsageView", () => { ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); }); }); + +it("resolves retained usage and successful time without inventing old-daemon history", () => { + const success = entry({ windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { + fetchedAt: success.fetchedAt, + report: success.report as Extract, + }, + }; + expect(resolveUsageReport(failure)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: true, + messages: ["Rate limited"], + }); + expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({ + windows: [], + balances: [], + details: [], + planLabel: null, + successfulAt: null, + retained: false, + messages: ["Rate limited"], + }); + expect(resolveUsageReport(success)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: false, + messages: [], + }); +}); + +it("orders failed polls by attempt time even when successful data is older", () => { + const success = entry({}); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.fetchedAt, report: success.report }, + }; + expect(upsertReport([failure], success)).toEqual([failure]); + expect(settleReports([failure], [success])).toEqual([failure]); + expect(upsertReport([success], failure)).toEqual([failure]); +}); + +it("available and retained usage show the same successful completion time", () => { + const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" }; + if (success.report.status !== "available") throw new Error("fixture must be available"); + expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z"); + const failure: UsageReportEntry = { + ...success, + successfulAt: undefined, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt); + expect(upsertReport([failure], success)).toEqual([failure]); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ed8a39163a..1f1c82cce3 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,7 +1,7 @@ import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; -import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; +import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; export function usedPercent(window: UsageWindow): number | null { if (window.usedPct != null) return window.usedPct; @@ -32,8 +32,8 @@ export function usageWindowRowLabel(input: { } /** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string): string { - return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; +export function formatUsageFreshness(compactTimeAgo: string, retained = false): string { + return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; } /** A user-requested refresh of one report. The previous report stays on screen throughout. */ @@ -192,3 +192,40 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; } + +/** Current outcome owns messages; only a successful poll owns values and freshness. */ +export function resolveUsageReport(entry: UsageReportEntry) { + const available = entry.report.status === "available"; + // COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Older daemons cannot provide history; a failed attempt is never a successful update. + const snapshot = available ? null : (entry.lastSuccessful ?? null); + const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null); + const messages: string[] = []; + if (!available) { + if (entry.loginErrors) + messages.push( + ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`), + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + else messages.push(usageProblemText(entry.report)); + } + return { + windows: content?.windows ?? [], + balances: content?.balances ?? [], + details: content?.details ?? [], + planLabel: content?.planLabel ?? null, + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Old daemons expose only attempt time for available reports, never for failures. + successfulAt: available + ? (entry.successfulAt ?? entry.fetchedAt) + : (snapshot?.fetchedAt ?? null), + retained: snapshot !== null, + messages, + }; +} + +function usageProblemText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return usageCopy.problem(report.problem); + return ""; +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index 9965251595..8c2c2e58fd 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -257,9 +257,12 @@ describe("resolvePinnedUsage", () => { }); }); -it("keeps unavailable and error reports out of the sidebar summary", () => { +it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => { + if (claude.report.status !== "available" || codex.report.status !== "available") + throw new Error("fixtures must be available"); const expired: UsageReportEntry = { ...claude, + lastSuccessful: { fetchedAt: claude.fetchedAt, report: claude.report }, report: { status: "unavailable", problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, @@ -267,6 +270,7 @@ it("keeps unavailable and error reports out of the sidebar summary", () => { }; const failed: UsageReportEntry = { ...codex, + lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report }, report: { status: "error", error: "Store deleted" }, }; expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 5260a1c44b..79ea6b9f2d 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -26,7 +26,9 @@ export function UsageWindowBar({ onTogglePin, pinLabel, pinTestID, + retained = false, }: { + retained?: boolean; window: UsageWindow; displayAs: UsageDisplayAs; /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ @@ -40,10 +42,13 @@ export function UsageWindowBar({ const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); - const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const isAtRisk = + window.runsOutAt != null && + window.shortfallPct != null && + (!retained || Date.parse(window.runsOutAt) > Date.now()); const trailing = isAtRisk ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt); + : formatResetLabel(window.resetsAt, retained); const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts index 9c2e3e8bbf..d6d5b62ee1 100644 --- a/packages/cli/src/commands/usage/index.test.ts +++ b/packages/cli/src/commands/usage/index.test.ts @@ -277,3 +277,40 @@ describe("usage CLI registration", () => { expect(usage.commands[1]!.helpInformation()).toContain(""); }); }); + +it("shows retained values with current failures and separates successful time from latest attempt", async () => { + const success: UsageReportEntry = { ...reports[0]!, successfulAt: "2026-10-09T12:00:15Z" }; + const current = await runUsageCommand( + options, + { kind: "inspect", id: success.id }, + host([success]).connect, + ); + expect(render(current)).toContain("Latest attempt: 2026-10-09T12:00:00Z"); + expect(render(current)).toContain("Last successful: 2026-10-09T12:00:15Z"); + const currentList = await runUsageCommand(options, { kind: "list" }, host([success]).connect); + expect(render(currentList)).toContain("Last successful: 2026-10-09T12:00:15Z"); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const retained: UsageReportEntry = { + ...success, + fetchedAt: "2026-10-09T12:06:00Z", + report: { status: "error", error: "Rate limited" }, + successfulAt: undefined, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + const daemon = host([retained]); + const listed = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(render(listed)).toContain("Retained: Five hours: 12.5% used"); + expect(render(listed)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(listed)).toContain("Rate limited"); + expect(JSON.parse(render(listed, { format: "json" }))).toEqual([retained]); + const inspected = await runUsageCommand( + options, + { kind: "inspect", id: retained.id }, + daemon.connect, + ); + expect(render(inspected)).toContain("Latest attempt: 2026-10-09T12:06:00Z"); + expect(render(inspected)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(inspected)).toContain("Retained usage:"); + expect(render(inspected)).toContain("Five hours: 12.5% used"); + expect(render(inspected)).toContain("Reason: Rate limited"); +}); diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts index ecc4aba405..2ecadafc59 100644 --- a/packages/cli/src/commands/usage/presentation.ts +++ b/packages/cli/src/commands/usage/presentation.ts @@ -44,8 +44,18 @@ function summary(entry: UsageReportEntry): string[] { const lines = [failureText(report)]; for (const login of entry.loginErrors ?? []) lines.push(`${login.harness}: ${failureText(login.report)}`); + if (entry.lastSuccessful) { + lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`); + lines.push( + ...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`), + ); + } else lines.push("No successful update"); return lines; } + return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`]; +} + +function availableSummary(report: Extract): string[] { const lines = report.windows.map(windowText); lines.push(...(report.balances ?? []).map(balanceText)); return lines.length ? lines : ["-"]; @@ -68,7 +78,7 @@ const summarySchema: OutputSchema = { { header: "ACCOUNT", field: "account" }, { header: "STATUS", field: "status" }, { header: "USAGE", field: "usage" }, - { header: "FETCHED", field: "fetched" }, + { header: "FETCHED (LATEST ATTEMPT)", field: "fetched" }, ], }; @@ -79,30 +89,15 @@ function inspect(entry: UsageReportEntry): string { `Source ID: ${entry.sourceId}`, `Account: ${value(entry.account.label)}`, `Status: ${entry.report.status}`, - `Fetched: ${value(entry.fetchedAt)}`, + `Latest attempt: ${value(entry.fetchedAt)}`, ]; - const { report } = entry; - if (report.status === "available") { - lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); - if (!report.windows.length) lines.push(" -"); - for (const window of report.windows) { - lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); - if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); - if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); - } - lines.push("Balances:"); - if (!report.balances?.length) lines.push(" -"); - for (const balance of report.balances ?? []) { - lines.push( - ` ${balanceText(balance)} [${balance.id}]`, - ` Resets: ${value(balance.resetsAt)}`, - ); - } - lines.push("Details:"); - if (!report.details?.length) lines.push(" -"); - for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); - } else { - lines.push(`Reason: ${failureText(report)}`); + const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report; + const successfulAt = successfulTime(entry); + lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update"); + if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`); + if (report) { + if (entry.report.status !== "available") lines.push("Retained usage:"); + lines.push(...availableDetails(report)); } if (entry.loginErrors?.length) { lines.push("Login failures:"); @@ -135,3 +130,33 @@ export const usageSchema: OutputSchema = { return renderTable({ type: "list", data: rows, schema: summarySchema }, options); }, }; + +function availableDetails(report: Extract): string[] { + const lines: string[] = []; + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + return lines; +} + +function successfulTime(entry: UsageReportEntry): string | undefined { + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Only available reports from older daemons can use their attempt timestamp as freshness. + if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt; + return entry.lastSuccessful?.fetchedAt; +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c8fe60ff67..a7a2182d94 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6306,14 +6306,15 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [ }), z.object({ kind: z.literal("no_quota"), detail: z.string() }), ]); +const AvailableUsageReportSchema = z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), +}); export const UsageReportSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("available"), - planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema), - balances: z.array(ProviderUsageBalanceSchema).optional(), - details: z.array(ProviderUsageDetailSchema).optional(), - }), + AvailableUsageReportSchema, z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), z.object({ status: z.literal("error"), error: z.string() }), ]); @@ -6325,6 +6326,11 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + // Successful completion time; fetchedAt remains the attempt timestamp for ordering. + successfulAt: z.string().optional(), + lastSuccessful: z + .object({ fetchedAt: z.string(), report: AvailableUsageReportSchema }) + .optional(), loginErrors: z .array( z.object({ diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 2298d680a4..6903aa2cd1 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, ServerInfoStatusPayloadSchema, @@ -436,3 +437,40 @@ test("usage login errors are additive and older reports still parse", () => { expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); expect(legacy.parse(newMessage)).toEqual(oldMessage); }); + +test("usage entries accept older failures and additive successful snapshots", () => { + const oldEntry = { + id: "source:account", + sourceId: "source", + sourceLabel: "Source", + account: {}, + fetchedAt: "2026-10-17T10:06:00Z", + report: { status: "error", error: "Rate limited" }, + }; + expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry); + const retained = { + ...oldEntry, + lastSuccessful: { + fetchedAt: "2026-10-17T10:00:00Z", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + }; + expect(UsageReportEntrySchema.parse(retained)).toEqual(retained); + const available = { + ...oldEntry, + report: retained.lastSuccessful.report, + successfulAt: "2026-10-17T10:00:15Z", + }; + expect(UsageReportEntrySchema.parse(available)).toEqual(available); + expect( + UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available), + ).toEqual({ ...oldEntry, report: available.report }); + // An older client's entry shape strips the additive snapshot and still sees the failure. + expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry); + expect( + UsageReportEntrySchema.safeParse({ + ...oldEntry, + lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report }, + }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index eeea242788..f0173691c8 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -556,3 +556,208 @@ test("a hung login records its own timeout and still tries the next login", asyn { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, ]); }); + +test("retains the last successful poll across failures, cache expiry, and recovery", async () => { + let now = 0; + let report: unknown = { + status: "available", + windows: [{ id: "week", label: "Weekly", usedPct: 42 }], + }; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "quota", + discover: async () => [{ key: "account", input: {} }], + fetch: async () => report, + }), + ); + registry.register(source({ id: "other", discover: async () => [{ key: "account", input: {} }] })); + const [success] = await registry.listReports(); + now = 360_000; + // An unrelated write evicts the expired fetch result, but must not discard its snapshot. + await registry.listReports({ reportIds: ["other:account"], forceRefresh: true }); + report = { status: "error", error: "Rate limited" }; + const [failure] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(failure).toMatchObject({ + fetchedAt: new Date(now).toISOString(), + report, + lastSuccessful: { fetchedAt: success!.fetchedAt, report: success!.report }, + }); + now += 360_000; + const [repeated] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(repeated?.lastSuccessful).toEqual(failure?.lastSuccessful); + report = { status: "unavailable", problem: { kind: "no_quota", detail: "No quota" } }; + const [unavailable] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(unavailable?.lastSuccessful).toEqual(failure?.lastSuccessful); + now += 1000; + report = { status: "available", windows: [{ id: "week", label: "Weekly", usedPct: 43 }] }; + const [recovered] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(recovered?.report).toEqual(report); + expect(recovered?.lastSuccessful).toBeUndefined(); + expect(recovered?.fetchedAt).toBe(new Date(now).toISOString()); +}); + +test("successful history is isolated by login set and pruned when identities retire", async () => { + let login = "old"; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(); + const fixture = source({ + id: "source", + discover: async () => (present ? [{ key: "same", input: login }] : []), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + registry.register(fixture); + await registry.listReports(); + available = false; + login = "new"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + login = "old"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + registry.unregister("source"); + registry.register(fixture); + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); + +test("host fallback history never supplies an agent's failed login, and removed agents lose history", async () => { + const sessions = new Map([["agent", { provider: "codex", env: {}, sessionKey: "one" }]]); + let available = true; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "source", + label: "Source", + discover: async (scope) => + (scope.kind === "global" ? ["host", "agent"] : ["agent"]).map((input) => ({ + key: "same", + input, + })), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + await registry.listReports(); + available = false; + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + const [success] = await registry.listReports({ agentId: "agent", forceRefresh: true }); + available = false; + expect( + (await registry.listReports({ agentId: "agent", forceRefresh: true }))[0]?.lastSuccessful + ?.report, + ).toEqual(success?.report); + sessions.delete("agent"); + await registry.listReports(); + sessions.set("agent", { provider: "codex", env: {}, sessionKey: "two" }); + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); +}); + +test("a timed out poll retains successful values and late completion cannot replace the failure", async () => { + let now = 1000; + let finish!: (value: unknown) => void; + let slow = false; + const registry = new UsageSourceRegistry(() => now, 300_000, undefined, undefined, 10); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => + slow + ? new Promise((resolve) => { + finish = resolve; + }) + : { status: "available", windows: [] }, + }), + ); + const [success] = await registry.listReports(); + now = 2000; + slow = true; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + expect(failure?.lastSuccessful).toEqual({ + fetchedAt: success?.fetchedAt, + report: success?.report, + }); + finish({ status: "available", windows: [{ id: "late", label: "Late" }] }); + expect((await registry.listReports())[0]).toEqual(failure); +}); + +test("attempt ordering keeps its timestamp while retained freshness records successful completion", async () => { + let now = 1000; + let available = true; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => { + if (available) now = 2000; + return available + ? { status: "available", windows: [] } + : { status: "error", error: "failed" }; + }, + }), + ); + const [success] = await registry.listReports(); + expect(success?.fetchedAt).toBe(new Date(1000).toISOString()); + expect(success?.successfulAt).toBe(new Date(2000).toISOString()); + available = false; + now = 3000; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.fetchedAt).toBe(new Date(3000).toISOString()); + expect(failure?.lastSuccessful?.fetchedAt).toBe(success?.successfulAt); +}); + +test("failed discovery preserves history until discovery confirms account removal", async () => { + let discoveryFails = false; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(() => 1000, 300_000, { warn: () => {} }); + registry.register( + source({ + id: "source", + discover: async () => { + if (discoveryFails) throw new Error("Login store temporarily unavailable"); + return present ? [{ key: "account", input: {} }] : []; + }, + fetch: async () => + available + ? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] } + : { status: "error", error: "Rate limited" }, + }), + ); + const [success] = await registry.listReports(); + available = false; + discoveryFails = true; + const [duringDiscoveryFailure] = await registry.listReports({ forceRefresh: true }); + expect(duringDiscoveryFailure?.lastSuccessful).toEqual({ + fetchedAt: success?.successfulAt, + report: success?.report, + }); + discoveryFails = false; + const [afterDiscoveryRecovery] = await registry.listReports({ forceRefresh: true }); + expect(afterDiscoveryRecovery?.lastSuccessful).toEqual(duringDiscoveryFailure?.lastSuccessful); + expect(afterDiscoveryRecovery?.report).toEqual({ status: "error", error: "Rate limited" }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index c0c9d781e5..e9e2e9c8af 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -56,6 +56,7 @@ export class UsageSourceRegistry { private defaults = new Map(); private readonly byAgent = new Map(); private readonly cache = new Map(); + private readonly successful = new Map>(); private readonly pending = new Map>(); constructor( @@ -81,6 +82,8 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.successful.keys()) + if (key.startsWith(`${id}:`)) this.successful.delete(key); for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } @@ -96,7 +99,7 @@ export class UsageSourceRegistry { } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { - this.defaults = await this.discover({ kind: "global" }); + this.defaults = await this.discover({ kind: "global" }, this.defaults); this.mergeKnown(); ids = [...this.known.keys()]; } @@ -124,7 +127,11 @@ export class UsageSourceRegistry { private async discoverAgent(agentId: string): Promise { if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); const session = this.agents.usageSession(agentId); - if (!session) return []; + if (!session) { + this.byAgent.delete(agentId); + this.mergeKnown(); + return []; + } const previous = this.byAgent.get(agentId); const sameScope = previous?.sessionKey === session.sessionKey && @@ -168,9 +175,26 @@ export class UsageSourceRegistry { } } } + // Fetch freshness expires independently of successful snapshots. Retain only live identities. + const active = this.activeCacheKeys(); + for (const key of this.successful.keys()) if (!active.has(key)) this.successful.delete(key); + for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key); } - private async discover(scope: UsageScope): Promise> { + private activeCacheKeys(): Set { + return new Set( + [ + this.known, + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ].flatMap((reports) => [...reports].map(([id, report]) => cacheIdentity(id, report))), + ); + } + + private async discover( + scope: UsageScope, + previous: ReadonlyMap = new Map(), + ): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { const reports = new Map(); @@ -197,6 +221,11 @@ export class UsageSourceRegistry { } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); + // A failed store read does not confirm removal. Session discovery has no previous + // mapping because a resumed agent may use a different launch environment. + for (const [id, report] of previous) { + if (report.source === source) reports.set(id, report); + } } return reports; }), @@ -235,25 +264,42 @@ export class UsageSourceRegistry { private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { // Account identity groups cards; the ordered login set identifies a fetch result. - const cacheKey = `${id}:${createHash("sha256") - .update(JSON.stringify([known.label, known.logins.map(loginKey)])) - .digest("hex")}`; + const cacheKey = cacheIdentity(id, known); const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { + const fetchedAt = new Date(this.now()).toISOString(); + const outcome = await this.fetchWithFallback(known); + const completedAt = new Date(this.now()).toISOString(); const entry: UsageReportEntry = { id, sourceId: known.source.id, sourceLabel: known.source.label, icon: known.source.icon, account: { label: known.label }, - fetchedAt: new Date(this.now()).toISOString(), - ...(await this.fetchWithFallback(known)), + fetchedAt, + ...outcome, }; - if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); + if (entry.report.status === "available") { + entry.successfulAt = completedAt; + } else { + entry.lastSuccessful = this.successful.get(cacheKey); + } + if ( + this.sources.get(known.source.id) === known.source && + this.activeCacheKeys().has(cacheKey) + ) { + if (entry.report.status === "available") { + this.successful.set(cacheKey, { + fetchedAt: completedAt, + report: entry.report, + }); + } + this.writeCache(cacheKey, entry); + } return entry; })(); this.pending.set(cacheKey, request); @@ -346,3 +392,9 @@ function loginKey(login: Login): string { : value, ); } + +function cacheIdentity(id: string, known: KnownReport): string { + return `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; +}