From 2a56e5c7347e3a2ab99298f3fb84e6312b2ebeb9 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 13 Oct 2026 19:12:49 +0200 Subject: [PATCH] fix(ui): show soft budget incidents as warnings, not hard stops (#16201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Budget policies limit what an agent, a project, or the organization can spend. > - At the warning threshold (80% by default), the server opens a soft incident. It does not pause the scope. At 100%, it opens a hard incident and pauses the scope. > - The budget incident card always shows the hard-stop design. A soft incident shows "agent hard stop", "This scope is paused", and a "Keep paused" button. > - Operators read this as a paused agent. The policy card and the "Paused agents" counter for the same agent say something different. > - This pull request shows soft incidents as warnings. Hard stops do not change. > - The benefit is that the Costs and Budgets pages give one consistent and correct state. ## Linked Issues or Issue Description I searched open and closed issues and pull requests for "budget incident", "hard stop", "soft incident", and "BudgetIncidentCard". I found no issue for this bug. Refs #14306: that open PR also changes `BudgetIncidentCard`, for failed-action feedback. It does not change the soft and hard labels. The two PRs can conflict in this file. **What happened?** An agent budget policy reached its warning threshold. The Audit → Budgets page and the Audit → Costs overview showed the soft incident as "AGENT HARD STOP" with the banner "This scope is paused. New heartbeats will not start until you resolve the budget incident." The card showed "Raise budget & resume" and "Keep paused". The agent was not paused. Its policy card showed "Warning", and the "Paused agents" counter did not count it. **Expected behavior** A soft incident looks like a warning. It says that work continues. Its actions are "Raise budget" and "Dismiss warning". A hard incident keeps the current design and actions. **Steps to reproduce** 1. Create an agent budget policy with a $1,000 monthly limit and the default 80% warning threshold. 2. Record $800 of cost for that agent. 3. Open Audit → Budgets. 4. Look at the active incident for that agent. It says "agent hard stop" and "This scope is paused". **Paperclip version or commit** Seen on v2026.1012.0 (`bba6a45`). The same code is on `master` at `721e87ac0`. **Deployment mode** local_trusted (not deployment-specific). ## What Changed - `BudgetIncidentCard` reads `incident.thresholdType`. A `soft` incident shows: - The heading " budget warning", with amber styling and a warning icon. - The banner "Spending crossed the warning threshold. This warning does not pause work." - The actions "Raise budget" and "Dismiss warning". They call the same handlers as before (`raise_budget_and_resume` and `keep_paused`). For a soft incident, the server dismisses the incident for `keep_paused`, and the resume step has no effect because the scope is not paused. - A new-budget prefill of the current limit plus $10. Before, the prefill was equal to the current limit, so "Raise budget" did not change the limit. - A `hard` incident does not change: the same heading, banner, styling, prefill, and actions. - The "Active incidents" description on the Costs page now covers warnings and hard stops. - New tests in `BudgetIncidentCard.test.tsx` cover a hard stop, a soft warning, and a project hard stop. ## Verification - `cd ui && npx vitest run src/components/BudgetIncidentCard.test.tsx`: 3 tests pass. Before the fix, the soft-warning test failed with `expected 'agent hard stopOpen…' to contain 'agent budget warning'`. - `cd ui && npx vitest run src/pages/Costs src/components/Budget`: 31 tests pass. - Manual check in a running instance (v2026.1012.0 with this patch, seeded with an agent at 100% and an agent at 80% of a $1,000 budget), in light and dark themes: - The 100% agent shows the red "Agent hard stop", "This scope is paused", "Raise budget & resume", and "Keep paused". - The 80% agent shows the amber "Agent budget warning", the warning banner, a $1,010.00 prefill, "Raise budget", and "Dismiss warning". - "Paused agents: 1" agrees with the cards. - `ui` typecheck: no errors in the changed files. The local run had unrelated errors in `PluginOrganizationSwitcher` because the plugin SDK was not built. ## Risks - Low risk. The change is UI-only. Hard-stop rendering and behavior do not change. No API, server, or data changes. - The soft card now prefills a value above the current limit. Before, it prefilled the current limit. - PR #14306 changes the same component. One of the two PRs can need a small rebase. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code with tool use (shell, file edits, test runs, Playwright screenshot capture). A human requested and reviewed the change. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no `doc/` page describes this card, so no change was needed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Aron Prins Co-authored-by: Claude Opus 5.5 --- ui/src/components/BudgetIncidentCard.test.tsx | 91 +++++++++++++++++++ ui/src/components/BudgetIncidentCard.tsx | 58 ++++++++---- ui/src/pages/Costs.tsx | 2 +- 3 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 ui/src/components/BudgetIncidentCard.test.tsx diff --git a/ui/src/components/BudgetIncidentCard.test.tsx b/ui/src/components/BudgetIncidentCard.test.tsx new file mode 100644 index 0000000000..2bde9f13b7 --- /dev/null +++ b/ui/src/components/BudgetIncidentCard.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { BudgetIncident } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BudgetIncidentCard } from "./BudgetIncidentCard"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function incident(overrides: Partial = {}): BudgetIncident { + return { + id: "incident-1", + companyId: "company-1", + policyId: "policy-1", + scopeType: "agent", + scopeId: "agent-1", + scopeName: "Budget Demo", + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart: new Date("2026-10-01T00:00:00Z"), + windowEnd: new Date("2026-11-01T00:00:00Z"), + thresholdType: "hard", + amountLimit: 100_000, + amountObserved: 100_000, + status: "open", + approvalId: null, + approvalStatus: null, + resolvedAt: null, + createdAt: new Date("2026-10-12T00:00:00Z"), + updatedAt: new Date("2026-10-12T00:00:00Z"), + ...overrides, + }; +} + +async function renderCard(value: BudgetIncident) { + const div = document.createElement("div"); + document.body.append(div); + const root = createRoot(div); + const onKeepPaused = vi.fn(); + const onRaiseAndResume = vi.fn(); + await act(async () => root.render( + , + )); + const buttons = () => Array.from(div.querySelectorAll("button"), (button) => button.textContent?.trim()); + return { + div, + buttons, + onKeepPaused, + cleanup: async () => { + await act(async () => root.unmount()); + div.remove(); + }, + }; +} + +describe("BudgetIncidentCard", () => { + afterEach(() => vi.restoreAllMocks()); + + it("labels a hard-stop incident as paused", async () => { + const { div, buttons, cleanup } = await renderCard(incident()); + expect(div.textContent).toContain("agent hard stop"); + expect(div.textContent).toContain("This scope is paused."); + expect(buttons()).toEqual(["Raise budget & resume", "Keep paused"]); + expect(div.querySelector("input")?.value).toBe("1010.00"); + await cleanup(); + }); + + it("labels a soft-threshold incident as a warning that does not pause the scope", async () => { + const { div, buttons, onKeepPaused, cleanup } = await renderCard( + incident({ thresholdType: "soft", amountObserved: 80_000 }), + ); + expect(div.textContent).toContain("agent budget warning"); + expect(div.textContent).not.toContain("hard stop"); + expect(div.textContent).not.toContain("paused"); + expect(div.textContent).toContain("This warning does not pause work."); + expect(div.textContent).toContain("Spending reached $800.00 against a limit of $1,000.00."); + expect(buttons()).toEqual(["Raise budget", "Dismiss warning"]); + expect(div.querySelector("input")?.value).toBe("1010.00"); + const dismiss = Array.from(div.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Dismiss warning")!; + await act(async () => dismiss.click()); + expect(onKeepPaused).toHaveBeenCalledTimes(1); + await cleanup(); + }); + + it("describes a paused project for a project hard stop", async () => { + const { div, cleanup } = await renderCard(incident({ scopeType: "project", scopeName: "Website" })); + expect(div.textContent).toContain("project hard stop"); + expect(div.textContent).toContain("Project execution is paused."); + await cleanup(); + }); +}); diff --git a/ui/src/components/BudgetIncidentCard.tsx b/ui/src/components/BudgetIncidentCard.tsx index 8be14d2542..b99b53ad1f 100644 --- a/ui/src/components/BudgetIncidentCard.tsx +++ b/ui/src/components/BudgetIncidentCard.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { BudgetIncident } from "@paperclipai/shared"; -import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react"; +import { AlertOctagon, AlertTriangle, ArrowUpRight, PauseCircle } from "lucide-react"; import { formatCents } from "../lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -36,46 +36,66 @@ export function BudgetIncidentCard({ onKeepPaused: () => void; isMutating?: boolean; }) { + const isWarning = incident.thresholdType === "soft"; const [draftAmount, setDraftAmount] = useState( - centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)), + centsInputValue(isWarning + ? Math.max(incident.amountObserved, incident.amountLimit) + 1000 + : Math.max(incident.amountObserved + 1000, incident.amountLimit)), ); const parsed = parseDollarInput(draftAmount); const stateLabel = incidentStateLabel(incident); return ( - +
-
- {incident.scopeType} hard stop +
+ {incident.scopeType} {isWarning ? "budget warning" : "hard stop"}
{stateLabel}
- {incident.scopeName} - + {incident.scopeName} + {incident.thresholdType === "hard" && incident.amountObserved < incident.amountLimit ? `Accounting is incomplete. Known spend is ${formatCents(incident.amountObserved)}; review pending or unpriced usage in the budget policy.` : `Spending reached ${formatCents(incident.amountObserved)} against a limit of ${formatCents(incident.amountLimit)}.`}
-
- -
+ {isWarning ? ( +
+ +
+ ) : ( +
+ +
+ )}
-
- -
- {incident.scopeType === "project" - ? "Project execution is paused. New work in this project will not start until you resolve the budget incident." - : "This scope is paused. New heartbeats will not start until you resolve the budget incident."} + {isWarning ? ( +
+ +
+ Spending crossed the warning threshold. This warning does not pause work. +
-
+ ) : ( +
+ +
+ {incident.scopeType === "project" + ? "Project execution is paused. New work in this project will not start until you resolve the budget incident." + : "This scope is paused. New heartbeats will not start until you resolve the budget incident."} +
+
+ )}
{parsed !== null && parsed <= incident.amountObserved ? ( @@ -108,7 +128,7 @@ export function BudgetIncidentCard({
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 4bee302312..e1f067c8d4 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -952,7 +952,7 @@ export function Costs({

Active incidents

- Resolve hard stops here by raising the budget or explicitly keeping the scope paused. + Raise the budget or keep a hard stop paused. Dismiss a warning when no action is needed.