feat(FN-1265): add agent budget settings UI and API

- Add Budget Settings section to ConfigTab with token limit, usage threshold, budget period, and reset day inputs
- Add budget usage badge to DashboardTab showing token consumption and threshold warning
- Add budget API functions to api.ts (getBudgetSummary, updateBudgetConfig, deleteBudgetUsage)
- Add budget routes to routes.ts for GET/PATCH/DELETE agent budget endpoints
- Add CSS styles for budget progress bar, warning indicators, and section layout
- Add comprehensive tests for budget UI components and API routes
This commit is contained in:
gsxdsm
2026-04-10 00:25:17 -07:00
parent 7f7da79443
commit 028d7f44cc
6 changed files with 639 additions and 4 deletions

View File

@@ -1975,8 +1975,9 @@ import type {
AgentReflection,
AgentPerformanceSummary,
ReflectionTrigger,
AgentBudgetStatus,
} from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger };
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus };
function withProjectId(path: string, projectId?: string): string {
if (!projectId) return path;
@@ -3799,6 +3800,20 @@ export function deleteAgentRating(agentId: string, ratingId: string, projectId?:
});
}
// ── Agent Budget API ──────────────────────────────────────────────────────
/** Fetch budget status for an agent */
export function fetchAgentBudgetStatus(agentId: string, projectId?: string): Promise<AgentBudgetStatus> {
return api<AgentBudgetStatus>(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget`, projectId));
}
/** Reset budget usage for an agent */
export function resetAgentBudget(agentId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget/reset`, projectId), {
method: "POST",
});
}
// ── Plugin Management ────────────────────────────────────────────────────────
/** Fetch all installed plugins */

View File

@@ -5,8 +5,8 @@ import {
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight, BarChart3, Star
} from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
@@ -502,6 +502,14 @@ function DashboardTab({
const stateStyle = STATE_COLORS[agent.state];
const [chainOfCommand, setChainOfCommand] = useState<Agent[]>([]);
const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true);
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
// Fetch budget status on mount
useEffect(() => {
fetchAgentBudgetStatus(agent.id, projectId)
.then(setBudgetStatus)
.catch(() => setBudgetStatus(null));
}, [agent.id, projectId]);
useEffect(() => {
let cancelled = false;
@@ -556,6 +564,14 @@ function DashboardTab({
return (
<div className="dashboard-tab">
{/* Budget Exhausted Warning */}
{budgetStatus?.isOverBudget && (
<div className="budget-warning-banner" role="alert">
<span>⚠️</span>
<span><strong>Budget Exhausted:</strong> This agent has exceeded its token budget and may be operating with limited functionality.</span>
</div>
)}
{/* Agent Info Card */}
<div className="dashboard-section">
<h3>Agent Information</h3>
@@ -585,6 +601,33 @@ function DashboardTab({
{health.label}
</span>
</div>
{budgetStatus?.budgetLimit != null && (
<div className="info-item">
<span className="info-label">Budget</span>
<span className="info-value">
<span
className="budget-badge"
style={{
background: budgetStatus.isOverBudget
? "var(--state-error-bg, rgba(248,81,73,0.15))"
: budgetStatus.isOverThreshold
? "var(--state-paused-bg, rgba(227,181,65,0.15))"
: "var(--state-active-bg, rgba(63,185,80,0.15))",
color: budgetStatus.isOverBudget
? "var(--state-error-text, #f85149)"
: budgetStatus.isOverThreshold
? "var(--state-paused-text, #e3b541)"
: "var(--state-active-text, #3fb950)",
border: `1px solid ${budgetStatus.isOverBudget ? "var(--state-error-border, #f85149)" : budgetStatus.isOverThreshold ? "var(--state-paused-border, #e3b541)" : "var(--state-active-border, #3fb950)"}`,
}}
>
{budgetStatus.isOverBudget
? "⚠ Budget Exhausted"
: `${Math.round(budgetStatus.usagePercent ?? 0)}% used`}
</span>
</span>
</div>
)}
<div className="info-item">
<span className="info-label">Created</span>
<span className="info-value">{new Date(agent.createdAt).toLocaleDateString()}</span>
@@ -1901,6 +1944,32 @@ function ConfigTab({
return initial;
});
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
// Fetch budget status on mount
useEffect(() => {
fetchAgentBudgetStatus(agent.id, projectId)
.then(setBudgetStatus)
.catch(() => setBudgetStatus(null));
}, [agent.id, projectId]);
const handleResetBudget = async () => {
setIsResettingBudget(true);
try {
await resetAgentBudget(agent.id, projectId);
addToast("Budget usage reset successfully", "success");
// Refresh budget status
const status = await fetchAgentBudgetStatus(agent.id, projectId);
setBudgetStatus(status);
} catch (err: any) {
addToast(`Failed to reset budget: ${err.message}`, "error");
} finally {
setIsResettingBudget(false);
}
};
const [isSaving, setIsSaving] = useState(false);
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
@@ -2365,6 +2434,54 @@ function ConfigTab({
</span>
)}
</div>
{/* Budget Usage Progress Bar */}
{budgetStatus?.budgetLimit != null && (
<div className="config-field">
<label>Current Usage</label>
<div className="budget-progress-container">
<div className="budget-progress-bar">
<div
className={cn(
"budget-progress-bar__fill",
(budgetStatus.usagePercent ?? 0) >= 100
? "budget-progress-bar__fill--red"
: (budgetStatus.usagePercent ?? 0) >= 80
? "budget-progress-bar__fill--amber"
: "budget-progress-bar__fill--green"
)}
style={{ width: `${Math.min(budgetStatus.usagePercent ?? 0, 100)}%` }}
/>
</div>
<span className="budget-progress-label">
{(budgetStatus.currentUsage ?? 0).toLocaleString()} / {(budgetStatus.budgetLimit ?? 0).toLocaleString()} tokens ({Math.round(budgetStatus.usagePercent ?? 0)}% used)
</span>
</div>
</div>
)}
{/* Reset Budget Button */}
{budgetStatus?.budgetLimit != null && (
<div className="config-field">
<button
className="btn btn-reset-budget"
onClick={() => void handleResetBudget()}
disabled={isResettingBudget}
>
{isResettingBudget ? (
<>
<Loader2 size={14} className="animate-spin" />
Resetting…
</>
) : (
<>
<RefreshCw size={14} />
Reset Budget Usage
</>
)}
</button>
</div>
)}
</div>
</div>

View File

@@ -22,6 +22,8 @@ vi.mock("../../api", () => ({
updateAgentMemory: vi.fn(),
fetchAgentTasks: vi.fn(),
fetchChainOfCommand: vi.fn(),
fetchAgentBudgetStatus: vi.fn(),
resetAgentBudget: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -32,7 +34,7 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand } from "../../api";
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
@@ -43,6 +45,8 @@ const mockFetchAgentRuns = vi.mocked(fetchAgentRuns);
const mockFetchAgentRunDetail = vi.mocked(fetchAgentRunDetail);
const mockFetchAgentTasks = vi.mocked(fetchAgentTasks);
const mockFetchChainOfCommand = vi.mocked(fetchChainOfCommand);
const mockFetchAgentBudgetStatus = vi.mocked(fetchAgentBudgetStatus);
const mockResetAgentBudget = vi.mocked(resetAgentBudget);
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -98,6 +102,19 @@ describe("AgentDetailView", () => {
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
// Default: no budget limit configured
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: null,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: null,
nextResetAt: null,
});
mockResetAgentBudget.mockResolvedValue(undefined);
});
it("shows loading state initially", () => {
@@ -1604,6 +1621,146 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
it("shows budget progress bar when budget status has limit configured", async () => {
// Need to mock twice: once for DashboardTab and once for ConfigTab
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 40000,
budgetLimit: 50000,
usagePercent: 80,
isOverBudget: false,
isOverThreshold: true,
budgetPeriod: "monthly",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("40,000 / 50,000 tokens (80% used)")).toBeInTheDocument();
});
});
it("hides progress bar when no budget limit is configured", async () => {
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 10000,
budgetLimit: null,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: null,
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
// Progress bar should not be visible
expect(screen.queryByText(/tokens/)).not.toBeInTheDocument();
});
});
it("shows Reset Budget button when budget limit is configured", async () => {
// Need to mock twice: once for DashboardTab and once for ConfigTab
mockFetchAgentBudgetStatus.mockResolvedValue({
agentId: "agent-001",
currentUsage: 30000,
budgetLimit: 50000,
usagePercent: 60,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
});
});
it("calls resetAgentBudget when Reset Budget button is clicked", async () => {
const addToast = vi.fn();
// First call (ConfigTab on mount)
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 30000,
budgetLimit: 50000,
usagePercent: 60,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
});
// Second call (after reset)
mockFetchAgentBudgetStatus.mockResolvedValueOnce({
agentId: "agent-001",
currentUsage: 0,
budgetLimit: 50000,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime",
lastResetAt: "2026-04-10T00:00:00.000Z",
nextResetAt: null,
});
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={addToast}
/>
);
await navigateToSettings(user);
await waitFor(() => {
expect(screen.getByText("Reset Budget Usage")).toBeInTheDocument();
});
await user.click(screen.getByText("Reset Budget Usage"));
await waitFor(() => {
expect(mockResetAgentBudget).toHaveBeenCalledWith("agent-001", undefined);
expect(addToast).toHaveBeenCalledWith("Budget usage reset successfully", "success");
});
});
});
// ── Runs Tab — Click to show logs ──────────────────────────────────

View File

@@ -26195,3 +26195,76 @@ html .column.drag-over * {
transform: rotate(360deg);
}
}
/* ── Budget Progress Bar ────────────────────────────────────────────────────── */
.budget-progress-container {
margin-top: 4px;
}
.budget-progress-bar {
width: 100%;
height: 8px;
background: var(--bg-secondary, #161b22);
border-radius: 4px;
overflow: hidden;
margin-top: 4px;
}
.budget-progress-bar__fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease, background-color 0.3s ease;
}
.budget-progress-bar__fill--green {
background: var(--state-active-text, #3fb950);
}
.budget-progress-bar__fill--amber {
background: var(--state-paused-text, #e3b541);
}
.budget-progress-bar__fill--red {
background: var(--state-error-text, #f85149);
}
.budget-progress-label {
font-size: 0.75rem;
color: var(--text-secondary, #8b949e);
margin-top: 4px;
display: block;
}
/* ── Budget Badge ────────────────────────────────────────────────────────────── */
.budget-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
/* ── Budget Warning Banner ──────────────────────────────────────────────────── */
.budget-warning-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
background: var(--state-error-bg, rgba(248,81,73,0.15));
color: var(--state-error-text, #f85149);
border: 1px solid var(--state-error-border, #f85149);
font-size: 0.875rem;
margin-bottom: 12px;
}
/* ── Budget Reset Button ─────────────────────────────────────────────────────── */
.btn-reset-budget {
margin-top: 8px;
}

View File

@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { get, request } from "../test-request.js";
// ── Mock @fusion/core for budget routes ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockGetAgent = vi.fn();
const mockGetBudgetStatus = vi.fn();
const mockResetBudgetUsage = vi.fn();
vi.mock("@fusion/core", () => {
return {
AgentStore: class MockAgentStore {
init = mockInit;
getAgent = mockGetAgent;
getBudgetStatus = mockGetBudgetStatus;
resetBudgetUsage = mockResetBudgetUsage;
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1265-test";
}
getFusionDir(): string {
return "/tmp/fn-1265-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockBudgetStatus(overrides: Record<string, unknown> = {}) {
return {
agentId: "agent-001",
currentUsage: 0,
budgetLimit: 50000,
usagePercent: 0,
isOverBudget: false,
isOverThreshold: false,
budgetPeriod: "lifetime" as const,
lastResetAt: "2026-01-01T00:00:00.000Z",
nextResetAt: null,
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("Agent budget routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
beforeEach(async () => {
vi.clearAllMocks();
mockInit.mockResolvedValue(undefined);
mockGetAgent.mockResolvedValue({ id: "agent-001", state: "running" });
mockGetBudgetStatus.mockResolvedValue(createMockBudgetStatus());
mockResetBudgetUsage.mockResolvedValue(undefined);
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/agents/:id/budget", () => {
it("returns budget status for agent", async () => {
const mockStatus = createMockBudgetStatus({
currentUsage: 40000,
usagePercent: 80,
isOverThreshold: true,
});
mockGetAgent.mockResolvedValueOnce({ id: "agent-001", state: "running" });
mockGetBudgetStatus.mockResolvedValueOnce(mockStatus);
const res = await get(app, "/api/agents/agent-001/budget");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
agentId: "agent-001",
currentUsage: 40000,
budgetLimit: 50000,
usagePercent: 80,
isOverThreshold: true,
isOverBudget: false,
});
});
it("returns 404 when agent not found", async () => {
mockGetAgent.mockResolvedValueOnce(null);
const res = await get(app, "/api/agents/nonexistent/budget");
expect(res.status).toBe(404);
expect(res.body).toMatchObject({
error: "Agent not found",
});
});
it("returns 500 on unexpected error", async () => {
mockGetAgent.mockRejectedValueOnce(new Error("Database error"));
const res = await get(app, "/api/agents/agent-001/budget");
expect(res.status).toBe(500);
expect(res.body).toHaveProperty("error");
});
it("returns budget status with over-budget flag", async () => {
const mockStatus = createMockBudgetStatus({
currentUsage: 55000,
usagePercent: 110,
isOverBudget: true,
isOverThreshold: true,
});
mockGetAgent.mockResolvedValueOnce({ id: "agent-001", state: "running" });
mockGetBudgetStatus.mockResolvedValueOnce(mockStatus);
const res = await get(app, "/api/agents/agent-001/budget");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
isOverBudget: true,
isOverThreshold: true,
usagePercent: 110,
});
});
it("returns budget status with no limit configured", async () => {
const mockStatus = createMockBudgetStatus({
budgetLimit: null,
currentUsage: 10000,
usagePercent: 0,
});
mockGetAgent.mockResolvedValueOnce({ id: "agent-001", state: "running" });
mockGetBudgetStatus.mockResolvedValueOnce(mockStatus);
const res = await get(app, "/api/agents/agent-001/budget");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
budgetLimit: null,
currentUsage: 10000,
});
});
});
describe("POST /api/agents/:id/budget/reset", () => {
it("resets budget and returns success", async () => {
mockGetAgent.mockResolvedValueOnce({ id: "agent-001", state: "running" });
mockResetBudgetUsage.mockResolvedValueOnce(undefined);
const res = await request(app, "POST", "/api/agents/agent-001/budget/reset");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(mockResetBudgetUsage).toHaveBeenCalledWith("agent-001");
});
it("returns 404 when agent not found", async () => {
mockGetAgent.mockResolvedValueOnce(null);
const res = await request(app, "POST", "/api/agents/nonexistent/budget/reset");
expect(res.status).toBe(404);
expect(res.body).toMatchObject({
error: "Agent not found",
});
});
it("returns 500 on unexpected error", async () => {
mockGetAgent.mockRejectedValueOnce(new Error("Database error"));
const res = await request(app, "POST", "/api/agents/agent-001/budget/reset");
expect(res.status).toBe(500);
expect(res.body).toHaveProperty("error");
});
it("calls resetBudgetUsage with correct agent ID", async () => {
mockGetAgent.mockResolvedValueOnce({ id: "agent-001", state: "running" });
mockResetBudgetUsage.mockResolvedValueOnce(undefined);
const res = await request(app, "POST", "/api/agents/agent-001/budget/reset");
expect(res.status).toBe(200);
expect(mockResetBudgetUsage).toHaveBeenCalledTimes(1);
expect(mockResetBudgetUsage).toHaveBeenCalledWith("agent-001");
});
});
});

View File

@@ -8799,6 +8799,66 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
/**
* GET /api/agents/:id/budget
* Get budget status for an agent.
*/
router.get("/agents/:id/budget", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
throw notFound("Agent not found");
}
const budgetStatus = await agentStore.getBudgetStatus(req.params.id);
res.json(budgetStatus);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
} else {
rethrowAsApiError(err);
}
}
});
/**
* POST /api/agents/:id/budget/reset
* Reset budget usage for an agent.
*/
router.post("/agents/:id/budget/reset", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(req.params.id);
if (!agent) {
throw notFound("Agent not found");
}
await agentStore.resetBudgetUsage(req.params.id);
res.json({ success: true });
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
if (err.message?.includes("not found")) {
throw notFound(err.message);
} else {
rethrowAsApiError(err);
}
}
});
/**
* POST /api/agents/:id/keys
* Create a new API key for an agent.