fix(ui): show soft budget incidents as warnings, not hard stops (#16201)

## 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 "<scope> 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 <aronprins@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 10:12:49 -07:00
1 parent e43af9770a
commit 2a56e5c734
3 files changed
+131 -20

No files matched your search

@@ -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> = {}): 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(
<BudgetIncidentCard incident={value} onKeepPaused={onKeepPaused} onRaiseAndResume={onRaiseAndResume} />,
));
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();
});
});
+39 -19
View File
@@ -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 (
<Card className="overflow-hidden border-red-500/20 bg-(image:--gradient-extract-4)">
<Card className={isWarning ? "overflow-hidden border-amber-500/30 bg-amber-500/5" : "overflow-hidden border-red-500/20 bg-(image:--gradient-extract-4)"}>
<CardHeader className="px-5 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-(length:--text-micro) uppercase tracking-(--tracking-caps) text-red-700/90 dark:text-red-200/80">
{incident.scopeType} hard stop
<div className={isWarning
? "text-(length:--text-micro) uppercase tracking-(--tracking-caps) text-amber-700 dark:text-amber-200"
: "text-(length:--text-micro) uppercase tracking-(--tracking-caps) text-red-700/90 dark:text-red-200/80"}>
{incident.scopeType} {isWarning ? "budget warning" : "hard stop"}
</div>
<Badge variant={incident.status === "resolved" ? "outline" : "secondary"}>
{stateLabel}
</Badge>
</div>
<CardTitle className="mt-1 text-base text-red-950 dark:text-red-50">{incident.scopeName}</CardTitle>
<CardDescription className="mt-1 text-red-900/75 dark:text-red-100/70">
<CardTitle className={isWarning ? "mt-1 text-base" : "mt-1 text-base text-red-950 dark:text-red-50"}>{incident.scopeName}</CardTitle>
<CardDescription className={isWarning ? "mt-1" : "mt-1 text-red-900/75 dark:text-red-100/70"}>
{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)}.`}
</CardDescription>
</div>
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-600 dark:text-red-200">
<AlertOctagon className="h-4 w-4" />
</div>
{isWarning ? (
<div className="rounded-full border border-amber-500/30 bg-amber-500/10 p-2 text-amber-600 dark:text-amber-200">
<AlertTriangle className="h-4 w-4" />
</div>
) : (
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-600 dark:text-red-200">
<AlertOctagon className="h-4 w-4" />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5 pt-0">
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-950/90 dark:text-red-50/90">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{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 ? (
<div className="flex items-start gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-950/90 dark:text-amber-50/90">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
Spending crossed the warning threshold. This warning does not pause work.
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-950/90 dark:text-red-50/90">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{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."}
</div>
</div>
)}
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
<label className="text-(length:--text-micro) uppercase tracking-(--tracking-caps) text-muted-foreground">
@@ -96,7 +116,7 @@ export function BudgetIncidentCard({
}}
>
<ArrowUpRight className="h-4 w-4" />
{isMutating ? "Applying..." : "Raise budget & resume"}
{isMutating ? "Applying..." : isWarning ? "Raise budget" : "Raise budget & resume"}
</Button>
</div>
{parsed !== null && parsed <= incident.amountObserved ? (
@@ -108,7 +128,7 @@ export function BudgetIncidentCard({
<div className="flex justify-end">
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
Keep paused
{isWarning ? "Dismiss warning" : "Keep paused"}
</Button>
</div>
</CardContent>
+1 -1
View File
@@ -952,7 +952,7 @@ export function Costs({
<div>
<h2 className="text-lg font-semibold">Active incidents</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-2">