Keep last successful usage visible when refresh fails (#7246)

* fix(usage): retain last successful data when polls fail

Keep the latest poll outcome separate from its successful snapshot so errors remain visible without discarding known usage or advancing its freshness. Preserve attempt timestamps for response ordering and isolate retained values by scoped login set.

* fix(usage): preserve history through discovery failures

A failed account-store read does not confirm that an account was removed. Retain its existing host mapping until successful discovery replaces it, without carrying mappings across agent launch environments.
This commit is contained in:
Mohamed Boudra
2026-10-18 11:06:31 +07:00
committed by GitHub
parent efd6d95f91
commit cf62b00911
16 changed files with 645 additions and 93 deletions
+65 -1
View File
@@ -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();
}
@@ -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);
}
+8 -2
View File
@@ -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<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
+26 -42
View File
@@ -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({
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.status === "available" && usage.planLabel ? (
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
) : null}
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
@@ -117,7 +90,8 @@ export function UsageCard({
{refreshable ? (
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
successfulAt={successfulAt}
retained={retained}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
@@ -138,12 +112,13 @@ export function UsageCard({
key={window.id}
entry={entry}
window={window}
retained={retained}
display={display}
pinnable={pinnable}
/>
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} />
<UsageBalanceBar key={balance.id} balance={balance} retained={retained} />
))}
</View>
) : null}
@@ -170,7 +145,8 @@ export function UsageCard({
</Text>
{showsFreshnessInline ? (
<UsageFreshness
fetchedAt={entry.fetchedAt}
successfulAt={successfulAt}
retained={retained}
style={styles.freshness}
testID="usage-freshness"
/>
@@ -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 (
<UsageWindowBar
window={window}
retained={retained}
displayAs={display.displayAs}
pinnable={pinnable}
pinned={display.isPinned(pin)}
@@ -220,13 +199,15 @@ function CardWindowBar({
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
function UsageRefreshButton({
sourceLabel,
fetchedAt,
successfulAt,
retained,
refreshState,
onRefresh,
compact,
}: {
sourceLabel: string;
fetchedAt: string;
successfulAt: string | null;
retained: boolean;
refreshState: UsageRefresh;
onRefresh: () => void;
compact: boolean;
@@ -236,12 +217,13 @@ function UsageRefreshButton({
const freshness = useMemo(
() => (
<UsageFreshness
fetchedAt={fetchedAt}
successfulAt={successfulAt}
retained={retained}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[fetchedAt],
[successfulAt, retained],
);
return (
<ToolbarButton
@@ -265,18 +247,20 @@ function UsageRefreshButton({
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({
fetchedAt,
successfulAt,
retained,
style,
testID,
}: {
fetchedAt: string;
successfulAt: string | null;
retained: boolean;
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
return (
<Text style={style} numberOfLines={1} testID={testID}>
{formatUsageFreshness(elapsed)}
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
</Text>
);
}
+8 -1
View File
@@ -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();
});
+2 -1
View File
@@ -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}`;
}
+70
View File
@@ -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<UsageReportEntry["report"], { status: "available" }>,
},
};
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]);
});
+40 -3
View File
@@ -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 "";
}
+5 -1
View File
@@ -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([]);
+7 -2
View File
@@ -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]);
@@ -277,3 +277,40 @@ describe("usage CLI registration", () => {
expect(usage.commands[1]!.helpInformation()).toContain("<report-id>");
});
});
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");
});
+49 -24
View File
@@ -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<UsageReport, { status: "available" }>): string[] {
const lines = report.windows.map(windowText);
lines.push(...(report.balances ?? []).map(balanceText));
return lines.length ? lines : ["-"];
@@ -68,7 +78,7 @@ const summarySchema: OutputSchema<SummaryRow> = {
{ 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<UsageReportEntry> = {
return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options);
},
};
function availableDetails(report: Extract<UsageReport, { status: "available" }>): 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;
}
+13 -7
View File
@@ -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({
@@ -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);
});
@@ -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();
});
@@ -56,6 +56,7 @@ export class UsageSourceRegistry {
private defaults = new Map<string, KnownReport>();
private readonly byAgent = new Map<string, AgentReports>();
private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>();
private readonly successful = new Map<string, NonNullable<UsageReportEntry["lastSuccessful"]>>();
private readonly pending = new Map<string, Promise<UsageReportEntry>>();
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<string[]> {
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<Map<string, KnownReport>> {
private activeCacheKeys(): Set<string> {
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<string, KnownReport> = new Map(),
): Promise<Map<string, KnownReport>> {
const discovered = await Promise.all(
[...this.sources.values()].map(async (source) => {
const reports = new Map<string, KnownReport>();
@@ -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<UsageReportEntry> {
// 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")}`;
}