feat(FN-4400): complete Step 7 — add prompt-size api and dashboard sparkline
Fusion-Task-Id: FN-4400 Fusion-Task-Lineage: 0d4f9c48-5b8b-49eb-9cf3-d1d585ffe7fc
This commit is contained in:
@@ -4714,6 +4714,14 @@ import type {
|
||||
} from "@fusion/core";
|
||||
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus };
|
||||
|
||||
export interface AgentPromptSizePoint {
|
||||
runId: string;
|
||||
createdAt: string;
|
||||
systemChars: number;
|
||||
execChars: number;
|
||||
totalChars: number;
|
||||
}
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
@@ -4928,6 +4936,15 @@ export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: st
|
||||
return api<AgentLogEntry[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId));
|
||||
}
|
||||
|
||||
/** Fetch recent prompt size points for an agent */
|
||||
export function fetchAgentPromptSizes(agentId: string, limit?: number, projectId?: string): Promise<AgentPromptSizePoint[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set("limit", String(limit));
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<AgentPromptSizePoint[]>(`/agents/${encodeURIComponent(agentId)}/prompt-sizes${query}`);
|
||||
}
|
||||
|
||||
/** Manually start a heartbeat run for an agent */
|
||||
export function startAgentRun(
|
||||
agentId: string,
|
||||
|
||||
@@ -892,6 +892,38 @@
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.prompt-size-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.prompt-size-sparkline {
|
||||
width: calc(var(--space-2xl) * 2);
|
||||
height: calc(var(--space-xl) + var(--space-md));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.prompt-size-sparkline-grid {
|
||||
fill: none;
|
||||
stroke: var(--text-muted);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.prompt-size-sparkline-line {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.prompt-size-values {
|
||||
font-family: var(--font-mono);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.run-agent-logs-section {
|
||||
margin-top: var(--space-xs);
|
||||
padding-top: var(--space-sm);
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead } from "../api";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType } from "@fusion/core";
|
||||
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
@@ -1684,6 +1684,7 @@ function RunsTab({
|
||||
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const [tokenUsageSummary, setTokenUsageSummary] = useState<AgentTokenUsageSummary | null>(null);
|
||||
const [promptSizes, setPromptSizes] = useState<AgentPromptSizePoint[]>([]);
|
||||
const hasAutoExpandedInitialRunRef = useRef(false);
|
||||
const didMountRunNowRefreshRef = useRef(false);
|
||||
|
||||
@@ -1706,12 +1707,12 @@ function RunsTab({
|
||||
useEffect(() => {
|
||||
if (isEphemeral) {
|
||||
setTokenUsageSummary(null);
|
||||
setPromptSizes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
void fetch(`/api/agents/${encodeURIComponent(agentId)}/token-usage${query}`, { signal: controller.signal })
|
||||
void fetch(`/api/agents/${encodeURIComponent(agentId)}/token-usage${query}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 400) {
|
||||
@@ -1724,13 +1725,19 @@ function RunsTab({
|
||||
setTokenUsageSummary(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
addToast(`Failed to load cache hit ratio: ${getErrorMessage(err)}`, "error");
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
void fetchAgentPromptSizes(agentId, 7, projectId)
|
||||
.then((data) => setPromptSizes(data))
|
||||
.catch((err) => {
|
||||
const message = getErrorMessage(err).toLowerCase();
|
||||
if (message.includes("ephemeral") || message.includes("400")) {
|
||||
setPromptSizes([]);
|
||||
return;
|
||||
}
|
||||
addToast(`Failed to load prompt sizes: ${getErrorMessage(err)}`, "error");
|
||||
});
|
||||
}, [agentId, projectId, addToast, isEphemeral]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2100,8 +2107,33 @@ function RunsTab({
|
||||
</div>
|
||||
);
|
||||
|
||||
const latestPrompt = promptSizes[0];
|
||||
const promptPoints = [...promptSizes].reverse();
|
||||
const maxExecChars = Math.max(1, ...promptPoints.map((point) => point.execChars));
|
||||
const promptPolyline = promptPoints
|
||||
.map((point, index) => {
|
||||
const x = promptPoints.length <= 1 ? 0 : (index / (promptPoints.length - 1)) * 100;
|
||||
const y = 100 - Math.round((point.execChars / maxExecChars) * 100);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="runs-tab">
|
||||
{promptSizes.length > 0 && latestPrompt && (
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Prompt Size</div>
|
||||
<div className="prompt-size-summary">
|
||||
<svg className="prompt-size-sparkline" viewBox="0 0 100 100" role="img" aria-label="Execution prompt size over last 7 runs">
|
||||
<polyline className="prompt-size-sparkline-grid" points="0,100 100,100" />
|
||||
<polyline className="prompt-size-sparkline-line" points={promptPolyline} />
|
||||
</svg>
|
||||
<span className="prompt-size-values">
|
||||
{latestPrompt.systemChars.toLocaleString()} / {latestPrompt.execChars.toLocaleString()} / {latestPrompt.totalChars.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tokenUsageSummary && (
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Cache hit ratio</div>
|
||||
@@ -3250,6 +3282,11 @@ function deriveHeartbeatScopeDiscipline(runtimeConfig: AgentDetail["runtimeConfi
|
||||
return mode === "strict" || mode === "lite" || mode === "off" ? mode : "";
|
||||
}
|
||||
|
||||
function deriveHeartbeatPromptTemplate(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): "default" | "compact" | "" {
|
||||
const template = runtimeConfig?.heartbeatPromptTemplate;
|
||||
return template === "default" || template === "compact" ? template : "";
|
||||
}
|
||||
|
||||
function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): Record<string, string> {
|
||||
const bc = (runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
|
||||
const nextValues: Record<string, string> = {};
|
||||
@@ -3625,6 +3662,9 @@ function ConfigTab({
|
||||
const [heartbeatScopeDiscipline, setHeartbeatScopeDiscipline] = useState<"strict" | "lite" | "off" | "">(
|
||||
() => deriveHeartbeatScopeDiscipline(agent.runtimeConfig),
|
||||
);
|
||||
const [heartbeatPromptTemplate, setHeartbeatPromptTemplate] = useState<"default" | "compact" | "">(
|
||||
() => deriveHeartbeatPromptTemplate(agent.runtimeConfig),
|
||||
);
|
||||
|
||||
// Budget config state initialised from agent.runtimeConfig.budgetConfig
|
||||
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(
|
||||
@@ -3913,6 +3953,7 @@ function ConfigTab({
|
||||
if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true;
|
||||
if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true;
|
||||
if (heartbeatScopeDiscipline !== deriveHeartbeatScopeDiscipline(agent.runtimeConfig)) return true;
|
||||
if (heartbeatPromptTemplate !== deriveHeartbeatPromptTemplate(agent.runtimeConfig)) return true;
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode", "autoClaimCandidatesInPrompt"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
let persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
@@ -4171,6 +4212,12 @@ function ConfigTab({
|
||||
newRuntimeConfig.heartbeatScopeDiscipline = heartbeatScopeDiscipline;
|
||||
}
|
||||
|
||||
if (!heartbeatPromptTemplate) {
|
||||
delete newRuntimeConfig.heartbeatPromptTemplate;
|
||||
} else {
|
||||
newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate;
|
||||
}
|
||||
|
||||
if (runtimeMode === "runtime") {
|
||||
if (selectedRuntimeId.trim()) {
|
||||
newRuntimeConfig.runtimeHint = selectedRuntimeId.trim();
|
||||
@@ -4249,7 +4296,7 @@ function ConfigTab({
|
||||
runtimeConfig: newRuntimeConfig,
|
||||
bundleConfig: newBundleConfig,
|
||||
};
|
||||
}, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]);
|
||||
}, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]);
|
||||
|
||||
const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => {
|
||||
const payload = buildSavePayload();
|
||||
@@ -4743,6 +4790,24 @@ function ConfigTab({
|
||||
<span className="config-hint">Strict — coordination-focused; higher per-tick tokens. Lite — pre-2026-05-11 behavior. Off — minimal procedure.</span>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatPromptTemplate">Heartbeat Prompt Template</label>
|
||||
<select
|
||||
id="hb-heartbeatPromptTemplate"
|
||||
className="select"
|
||||
value={heartbeatPromptTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setHeartbeatPromptTemplate(value === "default" || value === "compact" ? value : "");
|
||||
void scheduleAutoSave();
|
||||
}}
|
||||
>
|
||||
<option value="">Inherit project default</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-heartbeatIntervalMs">Heartbeat Interval (s)</label>
|
||||
<input
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
mockFetchAgentMemoryFiles,
|
||||
mockFetchAgentRunDetail,
|
||||
mockFetchAgentRunLogs,
|
||||
mockFetchAgentPromptSizes,
|
||||
mockFetchAgentRuns,
|
||||
mockFetchAgentTasks,
|
||||
mockFetchAgents,
|
||||
@@ -257,6 +258,10 @@ describe("Tasks tab", () => {
|
||||
describe("Runs Tab — click to show logs", () => {
|
||||
it("shows cache hit ratio section for permanent agents", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchAgentPromptSizes.mockResolvedValueOnce([
|
||||
{ runId: "run-1", createdAt: "2026-05-14T00:00:00.000Z", systemChars: 1500, execChars: 4800, totalChars: 6300 },
|
||||
{ runId: "run-2", createdAt: "2026-05-13T23:00:00.000Z", systemChars: 1200, execChars: 3900, totalChars: 5100 },
|
||||
]);
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByText("Runs"));
|
||||
@@ -264,10 +269,13 @@ describe("Runs Tab — click to show logs", () => {
|
||||
expect(screen.getByText("Cache hit ratio")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last 24h:/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/33.3%/)).toHaveLength(3);
|
||||
expect(screen.getByText("Prompt Size")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,500 / 4,800 / 6,300")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides cache hit ratio section for ephemeral agents", async () => {
|
||||
mockFetchAgentPromptSizes.mockRejectedValueOnce(new Error("400 Prompt sizes are not available for ephemeral agents"));
|
||||
const user = userEvent.setup();
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: { type: "spawned" } as any }));
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
|
||||
@@ -732,6 +732,48 @@ describe("Config autosave", () => {
|
||||
expect(latestPayload.runtimeConfig).toBeDefined();
|
||||
expect(latestPayload.runtimeConfig).not.toHaveProperty("heartbeatScopeDiscipline");
|
||||
});
|
||||
|
||||
it("saves heartbeat prompt template override", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: { heartbeatPromptTemplate: "default" },
|
||||
} as any));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const select = screen.getByLabelText("Heartbeat Prompt Template") as HTMLSelectElement;
|
||||
await user.selectOptions(select, "compact");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
expect(mockUpdateAgent.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
runtimeConfig: expect.objectContaining({ heartbeatPromptTemplate: "compact" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("clears heartbeat prompt template when inherit project default is selected", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: { heartbeatPromptTemplate: "compact" },
|
||||
} as any));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await openSettings(user);
|
||||
|
||||
const select = screen.getByLabelText("Heartbeat Prompt Template") as HTMLSelectElement;
|
||||
await user.selectOptions(select, "");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const latestPayload = mockUpdateAgent.mock.calls.at(-1)?.[1] as { runtimeConfig?: Record<string, unknown> };
|
||||
expect(latestPayload.runtimeConfig).toBeDefined();
|
||||
expect(latestPayload.runtimeConfig).not.toHaveProperty("heartbeatPromptTemplate");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export const mockFetchAgentChildren = vi.fn<ApiModule["fetchAgentChildren"]>();
|
||||
export const mockFetchAgentRunLogs = vi.fn<ApiModule["fetchAgentRunLogs"]>();
|
||||
export const mockFetchAgentRuns = vi.fn<ApiModule["fetchAgentRuns"]>();
|
||||
export const mockFetchAgentRunDetail = vi.fn<ApiModule["fetchAgentRunDetail"]>();
|
||||
export const mockFetchAgentPromptSizes = vi.fn<ApiModule["fetchAgentPromptSizes"]>();
|
||||
export const mockFetchAgentTasks = vi.fn<ApiModule["fetchAgentTasks"]>();
|
||||
export const mockFetchChainOfCommand = vi.fn<ApiModule["fetchChainOfCommand"]>();
|
||||
export const mockFetchAgentBudgetStatus = vi.fn<ApiModule["fetchAgentBudgetStatus"]>();
|
||||
@@ -60,6 +61,7 @@ vi.mock("../../api", () => ({
|
||||
fetchAgentChildren: (...args: Parameters<ApiModule["fetchAgentChildren"]>) => mockFetchAgentChildren(...args),
|
||||
fetchAgentRuns: (...args: Parameters<ApiModule["fetchAgentRuns"]>) => mockFetchAgentRuns(...args),
|
||||
fetchAgentRunDetail: (...args: Parameters<ApiModule["fetchAgentRunDetail"]>) => mockFetchAgentRunDetail(...args),
|
||||
fetchAgentPromptSizes: (...args: Parameters<ApiModule["fetchAgentPromptSizes"]>) => mockFetchAgentPromptSizes(...args),
|
||||
startAgentRun: (...args: Parameters<ApiModule["startAgentRun"]>) => mockStartAgentRun(...args),
|
||||
stopAgentRun: vi.fn(),
|
||||
updateAgentInstructions: (...args: Parameters<ApiModule["updateAgentInstructions"]>) => mockUpdateAgentInstructions(...args),
|
||||
@@ -257,6 +259,7 @@ export function setupAgentDetailMocks() {
|
||||
]);
|
||||
mockFetchAgentRunLogs.mockResolvedValue([]);
|
||||
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
|
||||
mockFetchAgentPromptSizes.mockResolvedValue([]);
|
||||
mockFetchAgentChildren.mockResolvedValue([]);
|
||||
mockFetchAgentTasks.mockResolvedValue([]);
|
||||
mockFetchChainOfCommand.mockResolvedValue([mockAgent]);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { get } from "../test-request.js";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const {
|
||||
mockInit,
|
||||
mockGetAgent,
|
||||
mockIsEphemeralAgent,
|
||||
mockChatStoreInit,
|
||||
mockAll,
|
||||
} = vi.hoisted(() => ({
|
||||
mockInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetAgent: vi.fn(),
|
||||
mockIsEphemeralAgent: vi.fn(),
|
||||
mockChatStoreInit: vi.fn().mockResolvedValue(undefined),
|
||||
mockAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockInit;
|
||||
getAgent = mockGetAgent;
|
||||
},
|
||||
isEphemeralAgent: mockIsEphemeralAgent,
|
||||
ChatStore: class MockChatStore {
|
||||
init = mockChatStoreInit;
|
||||
},
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
getRootDir(): string {
|
||||
return "/tmp/fn-4400-test";
|
||||
}
|
||||
|
||||
getFusionDir(): string {
|
||||
return "/tmp/fn-4400-test/.fusion";
|
||||
}
|
||||
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: mockAll,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("GET /api/agents/:id/prompt-sizes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetAgent.mockResolvedValue({ id: "agent-001", role: "executor", metadata: {} });
|
||||
mockIsEphemeralAgent.mockReturnValue(false);
|
||||
mockAll.mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
createdAt: "2026-05-14T00:00:00.000Z",
|
||||
systemChars: 120,
|
||||
execChars: 880,
|
||||
totalChars: 1000,
|
||||
},
|
||||
{
|
||||
runId: "run-2",
|
||||
createdAt: "2026-05-13T23:00:00.000Z",
|
||||
systemChars: 0,
|
||||
execChars: 0,
|
||||
totalChars: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns recent prompt size rows", async () => {
|
||||
const app = createServer(new MockStore() as any);
|
||||
const res = await get(app, "/api/agents/agent-001/prompt-sizes");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({ runId: "run-1", totalChars: 1000 }),
|
||||
expect.objectContaining({ runId: "run-2", systemChars: 0, execChars: 0, totalChars: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns 404 when agent is missing", async () => {
|
||||
mockGetAgent.mockResolvedValueOnce(null);
|
||||
const app = createServer(new MockStore() as any);
|
||||
const res = await get(app, "/api/agents/missing/prompt-sizes");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for ephemeral agents", async () => {
|
||||
mockIsEphemeralAgent.mockReturnValueOnce(true);
|
||||
const app = createServer(new MockStore() as any);
|
||||
const res = await get(app, "/api/agents/agent-001/prompt-sizes");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -631,6 +631,64 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/prompt-sizes
|
||||
* Get recent prompt-size points for a permanent agent.
|
||||
*/
|
||||
router.get("/agents/:id/prompt-sizes", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, isEphemeralAgent } = 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");
|
||||
}
|
||||
if (isEphemeralAgent(agent)) {
|
||||
throw badRequest("Prompt sizes are not available for ephemeral agents");
|
||||
}
|
||||
|
||||
const rawLimit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : 7;
|
||||
if (!Number.isInteger(rawLimit) || rawLimit <= 0) {
|
||||
throw badRequest("limit must be a positive integer");
|
||||
}
|
||||
const limit = Math.min(rawLimit, 30);
|
||||
|
||||
const rows = scopedStore.getDatabase().prepare(`
|
||||
SELECT
|
||||
id AS runId,
|
||||
createdAt,
|
||||
COALESCE(length(json_extract(data, '$.systemPrompt')), 0) AS systemChars,
|
||||
COALESCE(length(json_extract(data, '$.executionPrompt')), 0) AS execChars,
|
||||
COALESCE(length(json_extract(data, '$.systemPrompt')), 0)
|
||||
+ COALESCE(length(json_extract(data, '$.executionPrompt')), 0) AS totalChars
|
||||
FROM agentRuns
|
||||
WHERE json_extract(data, '$.agentId') = ?
|
||||
ORDER BY createdAt DESC
|
||||
LIMIT ?
|
||||
`).all(req.params.id, limit) as Array<{
|
||||
runId: string;
|
||||
createdAt: string;
|
||||
systemChars: number;
|
||||
execChars: number;
|
||||
totalChars: number;
|
||||
}>;
|
||||
|
||||
res.json(rows);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/token-usage
|
||||
* Get cache/token usage summary windows for a permanent agent.
|
||||
|
||||
Reference in New Issue
Block a user