mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user