From 03073afa85f38479b924d0b8c37a88bbac0df111 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 08:09:12 -0700 Subject: [PATCH] FN-7783: add Fable weekly usage window to Usage dropdown Adds parsing and display support for a Claude "Weekly (Fable)" usage window alongside existing Sonnet/Opus per-model windows. - fetchClaudeUsage parses seven_day_fable (with tolerant fallback keys: seven_day_claude_fable, fable, seven_day_fable_5) and pushes a Fable usage window - fetchClaudeUsageViaCli recognizes a "Current week (Fable" CLI section and renders it as "Weekly (Fable)" - Adds regression tests covering both API and CLI parsing paths - Adds changeset documenting the new minor feature Files changed: .changeset/fn-7783-fable-usage.md | 7 ++ packages/dashboard/src/__tests__/usage.test.ts | 117 ++++++++++++++++++++++++- packages/dashboard/src/usage.ts | 15 +++- 3 files changed, 134 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7783 Fusion-Task-Lineage: 0ed79ed5-15f3-4b6a-a443-d99653e72448 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7783-fable-usage.md | 7 ++ .../dashboard/src/__tests__/usage.test.ts | 117 +++++++++++++++++- packages/dashboard/src/usage.ts | 15 ++- 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-7783-fable-usage.md diff --git a/.changeset/fn-7783-fable-usage.md b/.changeset/fn-7783-fable-usage.md new file mode 100644 index 0000000000..5d606bbc8e --- /dev/null +++ b/.changeset/fn-7783-fable-usage.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show a Claude "Weekly (Fable)" usage window in the Usage dropdown. +category: feature +dev: usage.ts fetchClaudeUsage parses seven_day_fable (with tolerant fallback keys) and fetchClaudeUsageViaCli adds a "Current week (Fable" section; frontend renders it generically. API field name assumed seven_day_fable. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index f6735d67f9..a74ce4a20e 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -9,6 +9,11 @@ const coreInteropMocks = vi.hoisted(() => ({ readStoredCredentialsFromAuthFile: vi.fn(), })); +const nodePtyMocks = vi.hoisted(() => ({ + available: false, + spawn: vi.fn(), +})); + vi.mock("@fusion/core", async (importOriginal) => ({ ...(await importOriginal()), choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential, @@ -62,7 +67,10 @@ vi.mock("node:child_process", () => ({ // Mock node-pty for CLI fallback — default: not available (simulates test env) vi.mock("node-pty", () => { - throw new Error("node-pty not available in test environment"); + if (!nodePtyMocks.available) { + throw new Error("node-pty not available in test environment"); + } + return { spawn: nodePtyMocks.spawn }; }); describe("usage", () => { @@ -72,6 +80,8 @@ describe("usage", () => { mockRequest.mockClear(); mockReadFile.mockClear(); mockExecFileSync.mockClear(); + nodePtyMocks.available = false; + nodePtyMocks.spawn.mockReset(); mockExecFileSync.mockImplementation(() => { throw new Error("File not found"); }); @@ -772,7 +782,54 @@ describe("usage", () => { expect(sessionWindow!.resetText).toContain("resets in"); }); - it("parses all four usage windows from API response", async () => { + it("parses all five usage windows from API response", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + subscriptionType: "max", + }, + }); + + setupClaudeApiResponse({ + five_hour: { + utilization: 40.0, + resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + }, + seven_day: { + utilization: 20.0, + resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + seven_day_sonnet: { + utilization: 15.0, + resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + seven_day_opus: { + utilization: 5.0, + resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + seven_day_fable: { + utilization: 0, + resets_at: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), + }, + }); + + const providers = await fetchAllProviderUsage(); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.status).toBe("ok"); + expect(claude.windows).toHaveLength(5); + expect(claude.windows.map((w) => w.label)).toEqual([ + "Session (5h)", + "Weekly", + "Weekly (Sonnet)", + "Weekly (Opus)", + "Weekly (Fable)", + ]); + expect(claude.windows.find((w) => w.label === "Weekly (Fable)")?.percentUsed).toBe(0); + }); + + it("omits Weekly (Fable) when API response has no Fable window", async () => { setupClaudeMocks({ credFileContent: { accessToken: "test-token", @@ -804,13 +861,67 @@ describe("usage", () => { const claude = providers.find((p) => p.name === "Claude")!; expect(claude.status).toBe("ok"); - expect(claude.windows).toHaveLength(4); expect(claude.windows.map((w) => w.label)).toEqual([ "Session (5h)", "Weekly", "Weekly (Sonnet)", "Weekly (Opus)", ]); + expect(claude.windows.some((w) => w.label === "Weekly (Fable)")).toBe(false); + }); + + it("parses Weekly (Fable) from CLI fallback output after a 429 rate limit", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + }, + }); + + _setSleepFn(async () => {}); + nodePtyMocks.available = true; + nodePtyMocks.spawn.mockImplementation(() => ({ + write: vi.fn(), + kill: vi.fn(), + onData: vi.fn((handler: (data: string) => void) => { + handler([ + "Current week (Fable)", + "████ 12% used", + "Resets in 2d 4h", + ].join("\n")); + }), + onExit: vi.fn((handler: () => void) => { + handler(); + }), + })); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 429, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"error":"rate_limited"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.status).toBe("ok"); + expect(claude.windows).toHaveLength(1); + expect(claude.windows[0]).toMatchObject({ + label: "Weekly (Fable)", + percentUsed: 12, + percentLeft: 88, + }); + expect(mockRequest).toHaveBeenCalledTimes(3); + + _resetSleepFn(); }); it("falls back to CLI parsing on 429 rate limit", async () => { diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index c40c999f22..47ca0c5fe0 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -772,11 +772,12 @@ async function fetchClaudeUsageViaCli(): Promise { const lines = cleanOutput.split("\n").map((l) => l.trim()).filter(Boolean); // Find sections by looking for known headers (use LAST occurrence since PTY output has redraws) - const sections: { label: string; windowMs: number }[] = [ + const sections: { label: string; windowMs: number; displayLabel?: string }[] = [ { label: "Current session", windowMs: 5 * 60 * 60 * 1000 }, { label: "Current week (all models)", windowMs: 7 * 24 * 60 * 60 * 1000 }, { label: "Current week (Sonnet", windowMs: 7 * 24 * 60 * 60 * 1000 }, { label: "Current week (Opus", windowMs: 7 * 24 * 60 * 60 * 1000 }, + { label: "Current week (Fable", windowMs: 7 * 24 * 60 * 60 * 1000, displayLabel: "Weekly (Fable)" }, ]; usage.status = "ok"; @@ -806,7 +807,7 @@ async function fetchClaudeUsageViaCli(): Promise { if (percentUsed !== null) { const window: UsageWindow = { - label: section.label, + label: section.displayLabel ?? section.label, percentUsed: Math.min(100, Math.max(0, percentUsed)), percentLeft: Math.min(100, Math.max(0, 100 - percentUsed)), resetText, @@ -1117,11 +1118,21 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise