From e26b7585644258882688dcb436a80e591acfcf6b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 4 Oct 2026 03:03:09 -0700 Subject: [PATCH] fix: show initial budget load failures across cost views Co-Authored-By: Paperclip --- doc/COST-ACCOUNTING.md | 2 +- ui/src/pages/Costs.refresh.test.tsx | 37 ++++++++++++++++++++++++++++- ui/src/pages/Costs.tsx | 10 ++++---- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index 1413d13137b..f8eeca8df4b 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -160,7 +160,7 @@ Company quota requests resolve the caller's accessible subscription AI connectio Responses carry an opaque account identity and successful capture timestamp. The 30-second cache is scoped to company, user, connection, grant and credential revision; authorization and revision metadata are checked before reading cached results. Transient failures retain the last successful UI observation with its original timestamp. Rotation, revocation and explicit authentication rejection clear obsolete windows. Public messages never contain provider subprocess commands or raw diagnostics. -Both Costs page variants retain loaded reports and expanded rows during background refresh failures, with a small stale-data notice. Initial failures remain visible. Company/date changes isolate cached reports. The run-count label is `runs: 0 api · 11 sub`: distinct recorded runs, not tasks or individual model requests. Input totals include cache reads, with the cached portion shown explicitly. +Both Costs page variants retain loaded reports and expanded rows during background refresh failures, with a small stale-data notice. Initial failures remain visible. An initial budget-load failure shows one safe notice above the tabs, including when Budgets is hidden; successful polling restores incident controls automatically. Company/date changes isolate cached reports. The run-count label is `runs: 0 api · 11 sub`: distinct recorded runs, not tasks or individual model requests. Input totals include cache reads, with the cached portion shown explicitly. Receipt identity and supplemental charges: `heartbeat:` idempotency keys are reserved for internally generated run receipts. Additional API-reported charges may link to the same run with their own keys. They contribute to monthly and acknowledged-run lifetime totals, while receipt-integrity checks compare only the original provider receipt. Recovery isolates each budget scope so a deleted target or failed scope does not prevent recovery elsewhere. diff --git a/ui/src/pages/Costs.refresh.test.tsx b/ui/src/pages/Costs.refresh.test.tsx index a2e55f38215..9b38a3626a0 100644 --- a/ui/src/pages/Costs.refresh.test.tsx +++ b/ui/src/pages/Costs.refresh.test.tsx @@ -212,12 +212,47 @@ describe.each([ expect(container.textContent).not.toContain("0 total events in range"); }); - it("retains loaded budget controls and finance totals on background errors", async () => { + it("explains first-load budget failures on Overview and restores controls after polling succeeds", async () => { + overview.mockRejectedValue(new Error("internal budget stack trace")); await render(); + const message = "Budget data could not be loaded. Please try again shortly."; + const notices = () => [...container.querySelectorAll('[role="status"]')].filter(node => node.textContent === message); + expect(notices()).toHaveLength(1); + expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Overview"); + expect(container.textContent).toContain("Bender"); + expect(container.textContent).not.toContain("Showing the last loaded data"); + expect(container.textContent).not.toContain("internal budget"); + expect(container.textContent).not.toContain("Raise budget & resume"); + await render({ initialTab: "budgets", lockTab: true }); + expect(container.textContent?.split(message)).toHaveLength(2); + expect(container.textContent).not.toContain("Budget control plane"); + await render(); + overview.mockResolvedValue({ + policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }], + pendingApprovalCount: 1, pausedAgentCount: 1, pausedProjectCount: 0, + }); + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + await settle(); + expect(notices()).toHaveLength(0); + expect(container.textContent).toContain("Raise budget & resume"); + expect(container.textContent).toContain("Bender"); + }); + + it("retains loaded budget controls and finance totals on background errors", async () => { + overview.mockResolvedValue({ + policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }], + pendingApprovalCount: 1, pausedAgentCount: 1, pausedProjectCount: 0, + }); + await render(); + const resume = [...container.querySelectorAll("button")].find(node => node.textContent === "Raise budget & resume"); + expect(resume).toBeDefined(); overview.mockRejectedValue(new Error("internal budget failure")); api.financeEvents.mockRejectedValue(new Error("internal finance failure")); await act(async () => { await client.invalidateQueries(); }); await settle(); + expect([...container.querySelectorAll("button")].find(node => node.textContent === "Raise budget & resume")).toBe(resume); + expect(container.textContent).toContain("Showing the last loaded data"); + expect(container.textContent).not.toContain("Budget data could not be loaded"); if (name === "streamlined") { await render({ initialTab: "budgets", lockTab: true }); expect(container.querySelector('[role="tab"]')).toBeNull(); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index cc68f94b023..c4bd5c9e2d3 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -670,9 +670,11 @@ export function Costs({ ) : null} - {((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError) || providerError || billerError || weekError || weekBillerError || windowError) ? ( + {((spendData && spendError) || (financeData && financeError) || budgetError || providerError || billerError || weekError || weekBillerError || windowError) ? (

- Showing the last loaded data. Updates will resume automatically. + {budgetError && !budgetData + ? "Budget data could not be loaded. Please try again shortly." + : "Showing the last loaded data. Updates will resume automatically."}

) : null} @@ -911,9 +913,7 @@ export function Costs({ {policyMutation.error &&

{policyMutation.error.message}

} {budgetLoading ? ( - ) : budgetError && !budgetData ? ( -

Budget data could not be loaded. Please try again shortly.

- ) : ( + ) : budgetError && !budgetData ? null : ( <>