Skip to content

Commit 64a5a8e

Browse files
committed
feat(usage): make quota refresh reliable across ADE
1 parent c0e2ef0 commit 64a5a8e

45 files changed

Lines changed: 2107 additions & 418 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ade-cli/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,8 @@ printf %s "$TOKEN" | ade secrets set TOKEN --stdin
365365
ade secrets set TOKEN --value-file token.txt
366366
ade secrets delete STRIPE_API_KEY
367367
ade usage snapshot --text
368-
ade usage refresh --text
368+
ade --role cto usage refresh --text # live Claude/Codex quota only
369+
ade --role cto usage refresh --history --text # local provider history + costs
369370
ade usage budget get --text
370371
ade usage budget set --from-file budget.json
371372
ade usage budget check --provider claude --scope global

apps/ade-cli/src/adeRpcServer.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ function createRuntime() {
132132
daily: [],
133133
})),
134134
getUsageSnapshot: vi.fn(() => ({ available: true, entries: [] })),
135+
noteQuotaDemand: vi.fn(() => ({ available: true, entries: [] })),
135136
forceRefresh: vi.fn(async () => ({ available: true, entries: [] })),
137+
refreshHistory: vi.fn(async () => ({ available: true, entries: [] })),
136138
poll: vi.fn(async () => ({ available: true, entries: [] })),
137139
start: vi.fn(() => {}),
138140
stop: vi.fn(() => {}),
@@ -2599,6 +2601,12 @@ describe("adeRpcServer", () => {
25992601
expect(usageActions.structuredContent.actions).toContainEqual(
26002602
expect.objectContaining({ domain: "usage", action: "getAdeUsageStats", name: "usage.getAdeUsageStats" }),
26012603
);
2604+
expect(usageActions.structuredContent.actions).toContainEqual(
2605+
expect.objectContaining({ domain: "usage", action: "noteQuotaDemand", name: "usage.noteQuotaDemand" }),
2606+
);
2607+
expect(usageActions.structuredContent.actions).not.toContainEqual(
2608+
expect.objectContaining({ domain: "usage", action: "refreshHistory" }),
2609+
);
26022610

26032611
const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" });
26042612
expect(allDomains?.isError).toBeUndefined();
@@ -2727,6 +2735,15 @@ describe("adeRpcServer", () => {
27272735
expect(fixture.runtime.usageTrackingService.getAdeUsageStats).toHaveBeenCalledWith({ preset: "7d" });
27282736
expect(usageStats.structuredContent.result).toMatchObject({ preset: "7d", daily: [] });
27292737

2738+
const quotaDemand = await callTool(handler, "run_ade_action", {
2739+
domain: "usage",
2740+
action: "noteQuotaDemand",
2741+
args: {},
2742+
});
2743+
expect(quotaDemand?.isError).toBeUndefined();
2744+
expect(fixture.runtime.usageTrackingService.noteQuotaDemand).toHaveBeenCalledWith(undefined);
2745+
expect(quotaDemand.structuredContent.result).toEqual({ available: true, entries: [] });
2746+
27302747
});
27312748

27322749
it("records normalized ADE Code actions without recording terminal keystrokes", async () => {

apps/ade-cli/src/bootstrap.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1300,7 +1300,12 @@ export async function createAdeRuntime(args: {
13001300
});
13011301
const detachPushSources = publishPushEvents
13021302
? pushPublisherService.attachSources(projectId, {
1303-
agentChatService: agentChatService ?? null,
1303+
// The lightweight no-agent headless chat stub intentionally exposes
1304+
// only its request/response surface. Do not treat it as an event
1305+
// source unless it implements the full subscription contract.
1306+
agentChatService: typeof agentChatService?.subscribeToEvents === "function"
1307+
? agentChatService
1308+
: null,
13041309
ptyService,
13051310
subscribePrNotifications: (cb) => {
13061311
pushPrNotificationSubscribers.add(cb);

apps/ade-cli/src/cli.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7382,6 +7382,15 @@ describe("ADE CLI", () => {
73827382
expect(polled.kind).toBe("execute");
73837383
if (polled.kind !== "execute") return;
73847384
expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params);
7385+
7386+
const history = buildCliPlan(["usage", "refresh", "--history"]);
7387+
expect(history.kind).toBe("execute");
7388+
if (history.kind !== "execute") return;
7389+
expect(history.label).toBe("usage history refresh");
7390+
expect(history.steps[0]?.params).toEqual({
7391+
name: "run_ade_action",
7392+
arguments: { domain: "usage", action: "refreshHistory", args: {} },
7393+
});
73857394
});
73867395

73877396
it("usage budget get routes to the budget.getConfig action", () => {

apps/ade-cli/src/cli.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,19 +1961,17 @@ const HELP_BY_COMMAND: Record<string, string> = {
19611961
usage: `${ADE_BANNER}
19621962
Usage and provider quotas
19631963

1964-
Reads live provider quota usage (Claude five-hour + weekly, Codex five-hour +
1965-
weekly, Cursor monthly via the team Admin API), pacing, costs, and budget
1966-
guardrails. The desktop app surfaces this same data in the top-bar Usage popup.
1964+
Reads authoritative Claude and Codex quota windows, pacing, cached local
1965+
history, and budget guardrails. Live quota refresh is intentionally separate
1966+
from local provider-ledger scans.
19671967

1968-
$ ade usage snapshot --text Cached snapshot (windows, pacing, costs, errors)
1969-
$ ade usage refresh --text Force a fresh poll (invalidates cost cache)
1968+
$ ade usage snapshot --text Cached quota, source/stale state, and history
1969+
$ ade --role cto usage refresh --text Refresh live provider quota only
1970+
$ ade --role cto usage refresh --history --text Scan local provider history and costs
19701971
$ ade usage budget get --text Read budget guardrail config
19711972
$ ade usage budget set --from-file budget.json Save budget guardrail config
19721973
$ ade usage budget check --provider claude --scope global
19731974
$ ade usage budget cumulative --scope global Cumulative spend for the current week
1974-
1975-
Cursor uses the Admin API (https://api.cursor.com/teams/spend) — set
1976-
CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) so the poll can authenticate.
19771975
`,
19781976
secrets: `${ADE_BANNER}
19791977
ADE project secrets
@@ -9764,10 +9762,11 @@ function buildUsagePlan(args: string[]): CliPlan {
97649762
};
97659763
}
97669764
if (sub === "refresh" || sub === "poll") {
9765+
const history = args.includes("--history");
97679766
return {
97689767
kind: "execute",
9769-
label: "usage refresh",
9770-
steps: [actionStep("result", "usage", "forceRefresh", {})],
9768+
label: history ? "usage history refresh" : "usage refresh",
9769+
steps: [actionStep("result", "usage", history ? "refreshHistory" : "forceRefresh", {})],
97719770
};
97729771
}
97739772
if (sub === "budget") {

apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ function makePairingConnectInfo(
112112
describe("createSyncRemoteCommandService", () => {
113113
it("serves the cross-client usage snapshot to paired mobile and web clients", async () => {
114114
const getAdeUsageStats = vi.fn().mockResolvedValue({ generatedAt: "2026-07-09T12:00:00.000Z", daily: [] });
115-
const { service } = createService({ usageTrackingService: { getAdeUsageStats } });
115+
const quotaSnapshot = { windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] };
116+
const getUsageSnapshot = vi.fn(() => quotaSnapshot);
117+
const forceRefresh = vi.fn(async () => ({ ...quotaSnapshot, lastPolledAt: "2026-07-09T12:01:00.000Z" }));
118+
const { service } = createService({ usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } });
116119

117120
expect(service.getDescriptor("usage.getAdeStats")).toEqual({
118121
action: "usage.getAdeStats",
@@ -127,6 +130,22 @@ describe("createSyncRemoteCommandService", () => {
127130
await expect(service.execute(makePayload("usage.getAdeStats", { preset: "decade" }))).rejects.toThrow(
128131
"usage.getAdeStats preset must be today, 7d, 30d, year, or all.",
129132
);
133+
expect(service.getDescriptor("usage.getQuotaSnapshot")).toEqual({
134+
action: "usage.getQuotaSnapshot",
135+
scope: "project",
136+
policy: { viewerAllowed: true },
137+
});
138+
expect(service.getDescriptor("usage.refreshQuota")).toEqual({
139+
action: "usage.refreshQuota",
140+
scope: "project",
141+
policy: { viewerAllowed: true },
142+
});
143+
await expect(service.execute(makePayload("usage.getQuotaSnapshot"))).resolves.toEqual(quotaSnapshot);
144+
await expect(service.execute(makePayload("usage.refreshQuota"))).resolves.toMatchObject({
145+
lastPolledAt: "2026-07-09T12:01:00.000Z",
146+
});
147+
expect(getUsageSnapshot).toHaveBeenCalledTimes(1);
148+
expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false });
130149
});
131150

132151
it("advertises and executes machine personal chats as runtime commands", async () => {

apps/ade-cli/src/services/sync/syncRemoteCommandService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4532,6 +4532,16 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
45324532
});
45334533
});
45344534

4535+
register("usage.getQuotaSnapshot", { viewerAllowed: true }, async () => {
4536+
if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime.");
4537+
return args.usageTrackingService.getUsageSnapshot();
4538+
});
4539+
4540+
register("usage.refreshQuota", { viewerAllowed: true }, async () => {
4541+
if (!args.usageTrackingService) throw new Error("Usage quota is not available in this runtime.");
4542+
return await args.usageTrackingService.forceRefresh({ allowInteractiveAuth: false });
4543+
});
4544+
45354545
registerLaneRemoteCommands({ args, register });
45364546
registerWorkRemoteCommands({ args, register });
45374547
registerProcessRemoteCommands({ args, register });

apps/ade-cli/src/services/sync/syncService.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ describe("createSyncService", () => {
8787
preset: "year",
8888
daily: [],
8989
}));
90+
const getUsageSnapshot = vi.fn(() => ({ windows: [], lastPolledAt: "2026-07-09T12:00:00.000Z", errors: [] }));
91+
const forceRefresh = vi.fn(async () => ({ windows: [], lastPolledAt: "2026-07-09T12:01:00.000Z", errors: [] }));
9092
const service = createService(db, projectRoot, {
91-
usageTrackingService: { getAdeUsageStats } as any,
93+
usageTrackingService: { getAdeUsageStats, getUsageSnapshot, forceRefresh } as any,
9294
});
9395

9496
try {
@@ -103,6 +105,18 @@ describe("createSyncService", () => {
103105
args: { preset: "year" },
104106
})).resolves.toMatchObject({ preset: "year", daily: [] });
105107
expect(getAdeUsageStats).toHaveBeenCalledWith({ preset: "year" });
108+
await expect(service.executeRemoteCommand({
109+
commandId: "cmd-usage-quota",
110+
action: "usage.getQuotaSnapshot",
111+
args: {},
112+
})).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:00:00.000Z" });
113+
await expect(service.executeRemoteCommand({
114+
commandId: "cmd-refresh-quota",
115+
action: "usage.refreshQuota",
116+
args: {},
117+
})).resolves.toMatchObject({ lastPolledAt: "2026-07-09T12:01:00.000Z" });
118+
expect(getUsageSnapshot).toHaveBeenCalledTimes(1);
119+
expect(forceRefresh).toHaveBeenCalledWith({ allowInteractiveAuth: false });
106120
} finally {
107121
await service.dispose();
108122
db.close();

apps/ade-cli/src/tuiClient/__tests__/RightPane.usage.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ describe("RightPane usage", () => {
1919
const { lastFrame } = renderPane({
2020
kind: "usage",
2121
title: "Usage",
22+
providerStatuses: [{
23+
id: "claude",
24+
label: "Claude",
25+
state: "ok",
26+
source: "oauth",
27+
updatedAt: new Date().toISOString(),
28+
}],
2229
quotaWindows: [{ id: "rate-limit", label: "Rate limit", percent: 42, resetAt }],
2330
session: { input: 12_000, output: 3_400, cost: 0.42 },
2431
});
2532
const text = lastFrame() ?? "";
2633
expect(text).toContain("USAGE");
34+
expect(text).toContain("Claude");
35+
expect(text).toContain("OAUTH · just now");
2736
expect(text).toContain("Rate limit");
2837
expect(text).toContain("42%");
2938
// Live reset countdown marker.
@@ -35,6 +44,28 @@ describe("RightPane usage", () => {
3544
expect(text).toContain("$0.42");
3645
});
3746

47+
it("marks carried-forward provider data as stale without hiding quota windows", () => {
48+
const { lastFrame } = renderPane({
49+
kind: "usage",
50+
title: "Usage",
51+
providerStatuses: [{
52+
id: "codex",
53+
label: "Codex",
54+
state: "stale",
55+
source: "http",
56+
updatedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
57+
message: "Rate limited; retrying soon",
58+
}],
59+
quotaWindows: [{ id: "codex:weekly", label: "Codex weekly", percent: 61, resetAt: null }],
60+
session: null,
61+
});
62+
const text = lastFrame() ?? "";
63+
expect(text).toContain("HTTP · 2m ago");
64+
expect(text).toContain("STALE · Rate limited; retrying soon");
65+
expect(text).toContain("Codex weekly");
66+
expect(text).toContain("61%");
67+
});
68+
3869
it("degrades to the session block when quota windows are unavailable", () => {
3970
const { lastFrame } = renderPane({
4071
kind: "usage",

apps/ade-cli/src/tuiClient/__tests__/commands.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,13 @@ describe("commands", () => {
222222

223223
it("keeps provider-agnostic skills visible outside Claude chats", () => {
224224
const rows = paletteCommands("/", [], { provider: "codex" });
225-
for (const name of ["/agents", "/init", "/usage", "/insights", "/fast"]) {
225+
for (const name of ["/agents", "/init", "/insights", "/fast"]) {
226226
expect(rows).not.toContainEqual(expect.objectContaining({ name }));
227227
}
228+
expect(rows).toContainEqual(expect.objectContaining({
229+
name: "/usage",
230+
description: "Show Claude and Codex limits plus session usage",
231+
}));
228232
expect(rows).toContainEqual(expect.objectContaining({
229233
name: "/skills",
230234
description: "List agent skills from project, user, and ADE bundled roots",

0 commit comments

Comments
 (0)