From dcc6c1bd312f1742bda78b32ea085901a9189ff9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 4 Oct 2026 03:13:52 -0700 Subject: [PATCH] fix: keep budget and stale report notices independent Co-Authored-By: Paperclip --- doc/COST-ACCOUNTING.md | 2 +- ui/src/pages/Costs.refresh.test.tsx | 24 ++++++++++++++++++++++++ ui/src/pages/Costs.tsx | 11 +++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index f8eeca8df4b..976cb684149 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. 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. +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. Budget-load and stale-report notices remain independent when requests fail together. 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 9b38a3626a0..b256192299d 100644 --- a/ui/src/pages/Costs.refresh.test.tsx +++ b/ui/src/pages/Costs.refresh.test.tsx @@ -238,6 +238,30 @@ describe.each([ expect(container.textContent).toContain("Bender"); }); + it.each(["summary", "financeEvents", "byProvider", "byBiller", "windowSpend"])("shows independent budget and stale-report notices when %s refresh fails", async (method) => { + overview.mockRejectedValue(new Error("internal budget failure")); + await render(); + if (method === "byProvider" || method === "windowSpend") await click("Providers"); + if (method === "byBiller") await click("Billers"); + const successfulReport = api[method].getMockImplementation()!; + api[method].mockRejectedValue(new Error("internal report failure")); + await act(async () => { await client.invalidateQueries(); }); + await settle(); + expect(container.textContent).toContain("Budget data could not be loaded"); + expect(container.textContent).toContain("Showing the last loaded data"); + expect(container.textContent).toContain("$402.88"); + expect(container.textContent).not.toContain("internal"); + overview.mockResolvedValue({ policies: [], activeIncidents: [], pendingApprovalCount: 0, pausedAgentCount: 0, pausedProjectCount: 0 }); + await act(async () => { await client.invalidateQueries(); }); + await settle(); + expect(container.textContent).not.toContain("Budget data could not be loaded"); + expect(container.textContent).toContain("Showing the last loaded data"); + api[method].mockImplementation(successfulReport); + await act(async () => { await client.invalidateQueries(); }); + await settle(); + expect(container.textContent).not.toContain("Showing the last loaded data"); + }); + 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 }], diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index c4bd5c9e2d3..2ae19aca7e8 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -670,11 +670,14 @@ export function Costs({ ) : null} - {((spendData && spendError) || (financeData && financeError) || budgetError || providerError || billerError || weekError || weekBillerError || windowError) ? ( + {budgetError && !budgetData ? (

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

+ ) : null} + {((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError) || providerError || billerError || weekError || weekBillerError || windowError) ? ( +

+ Showing the last loaded data. Updates will resume automatically.

) : null}