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 db14d9e077
commit 9575350432
6 changed files with 639 additions and 4 deletions

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 ──────────────────────────────────