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:
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
213
packages/dashboard/src/__tests__/routes-agent-budget.test.ts
Normal file
213
packages/dashboard/src/__tests__/routes-agent-budget.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user