feat(FN-4388): complete remaining observability surfaces

Fusion-Task-Id: FN-4388
Fusion-Task-Lineage: 7497a0a1-5edb-4778-b339-118d1fd668f7
This commit is contained in:
Fusion
2026-05-13 22:33:42 -07:00
committed by gsxdsm
parent 819a499d21
commit 2838df2199
11 changed files with 297 additions and 6 deletions

View File

@@ -920,6 +920,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
initialRunId={initialRunId}
preferActiveRun={preferActiveRun}
runNowRefreshToken={runNowRefreshToken}
isEphemeral={isEphemeralAgent(agent)}
/>
)}
@@ -1638,6 +1639,21 @@ function MailTab({
// ── Runs Tab ───────────────────────────────────────────────────────────────
interface AgentTokenUsageWindowSummary {
totalInputTokens: number;
totalCachedTokens: number;
totalCacheWriteTokens: number;
totalOutputTokens: number;
nTasks: number;
hitRatio: number;
}
interface AgentTokenUsageSummary {
last24h: AgentTokenUsageWindowSummary;
last7d: AgentTokenUsageWindowSummary;
allTime: AgentTokenUsageWindowSummary;
}
function RunsTab({
addToast,
agentId,
@@ -1647,6 +1663,7 @@ function RunsTab({
initialRunId,
preferActiveRun,
runNowRefreshToken,
isEphemeral,
}: {
addToast: (msg: string, type?: "success" | "error") => void;
agentId: string;
@@ -1656,6 +1673,7 @@ function RunsTab({
initialRunId?: string | null;
preferActiveRun?: boolean;
runNowRefreshToken: number;
isEphemeral: boolean;
}) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const { confirm } = useConfirm();
@@ -1665,6 +1683,7 @@ function RunsTab({
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [detailRun, setDetailRun] = useState<AgentHeartbeatRun | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [tokenUsageSummary, setTokenUsageSummary] = useState<AgentTokenUsageSummary | null>(null);
const hasAutoExpandedInitialRunRef = useRef(false);
const didMountRunNowRefreshRef = useRef(false);
@@ -1684,6 +1703,36 @@ function RunsTab({
void loadRuns();
}, [loadRuns]);
useEffect(() => {
if (isEphemeral) {
setTokenUsageSummary(null);
return;
}
const controller = new AbortController();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
void fetch(`/api/agents/${encodeURIComponent(agentId)}/token-usage${query}`, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) {
if (res.status === 400) {
setTokenUsageSummary(null);
return;
}
throw new Error(`Request failed: ${res.status}`);
}
const data = (await res.json()) as AgentTokenUsageSummary;
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();
}, [agentId, projectId, addToast, isEphemeral]);
useEffect(() => {
if (!didMountRunNowRefreshRef.current) {
didMountRunNowRefreshRef.current = true;
@@ -2044,8 +2093,25 @@ function RunsTab({
);
};
const renderCacheWindow = (label: string, window: AgentTokenUsageWindowSummary) => (
<div className="run-context-item" key={label}>
<span className="text-muted">{label}:</span>{" "}
<span>{(window.hitRatio * 100).toFixed(1)}% ({window.totalCachedTokens.toLocaleString()} / {window.totalCacheWriteTokens.toLocaleString()} / {window.totalInputTokens.toLocaleString()} / {window.nTasks.toLocaleString()})</span>
</div>
);
return (
<div className="runs-tab">
{tokenUsageSummary && (
<div className="run-output-section">
<div className="run-output-label">Cache hit ratio</div>
<div className="run-context-grid">
{renderCacheWindow("Last 24h", tokenUsageSummary.last24h)}
{renderCacheWindow("Last 7d", tokenUsageSummary.last7d)}
{renderCacheWindow("All time", tokenUsageSummary.allTime)}
</div>
</div>
)}
<div className="runs-toolbar runs-toolbar--between">
<span className="runs-toolbar-meta">
{runs.length} run{runs.length !== 1 ? "s" : ""}

View File

@@ -132,6 +132,25 @@
font-size: calc(var(--space-sm) + var(--space-xs));
}
.task-token-stats-panel__cache-ratio {
color: var(--text);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.task-token-stats-panel__cache-ratio-label {
color: var(--text-muted);
}
.task-token-stats-panel__cache-ratio-value {
font-family: var(--font-mono);
font-weight: 700;
}
.task-token-stats-panel__cache-breakdown {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.task-token-stats-panel__empty,
.task-token-stats-panel__loading {
border: 1px dashed var(--border);

View File

@@ -41,6 +41,10 @@ function formatTokenCount(value: number): string {
return value.toLocaleString();
}
function formatHitRatio(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
function formatTimestamp(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
@@ -265,6 +269,17 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
</div>
</div>
<div className="task-token-stats-panel__cache-ratio">
<span className="task-token-stats-panel__cache-ratio-label">Cache hit ratio:</span>{" "}
<span className="task-token-stats-panel__cache-ratio-value">
{(tokenUsage.inputTokens + tokenUsage.cachedTokens) > 0
? formatHitRatio(tokenUsage.cachedTokens / (tokenUsage.inputTokens + tokenUsage.cachedTokens))
: "—"}
</span>
</div>
<div className="task-token-stats-panel__cache-breakdown">
(read {formatTokenCount(tokenUsage.cachedTokens)} / write {formatTokenCount(tokenUsage.cacheWriteTokens ?? 0)} / input {formatTokenCount(tokenUsage.inputTokens)})
</div>
<dl className="task-token-stats-panel__timestamps">
<div className="task-token-stats-panel__timestamp-row">
<dt>First used</dt>

View File

@@ -50,6 +50,17 @@ import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView — logs, tasks, and runs", () => {
beforeEach(() => {
setupAgentDetailMocks();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/token-usage")) {
return new Response(JSON.stringify({
last24h: { totalInputTokens: 100, totalCachedTokens: 50, totalCacheWriteTokens: 10, totalOutputTokens: 20, nTasks: 2, hitRatio: 0.3333 },
last7d: { totalInputTokens: 200, totalCachedTokens: 100, totalCacheWriteTokens: 20, totalOutputTokens: 40, nTasks: 3, hitRatio: 0.3333 },
allTime: { totalInputTokens: 300, totalCachedTokens: 150, totalCacheWriteTokens: 30, totalOutputTokens: 60, nTasks: 4, hitRatio: 0.3333 },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}));
});
describe("Logs tab", () => {
@@ -244,6 +255,36 @@ describe("Tasks tab", () => {
describe("Runs Tab — click to show logs", () => {
it("shows cache hit ratio section for permanent agents", async () => {
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Cache hit ratio")).toBeInTheDocument();
expect(screen.getByText(/Last 24h:/)).toBeInTheDocument();
expect(screen.getAllByText(/33.3%/)).toHaveLength(3);
});
});
it("hides cache hit ratio section for ephemeral agents", async () => {
const user = userEvent.setup();
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: { type: "spawned" } as any }));
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/token-usage")) {
return new Response(JSON.stringify({ error: "Token usage is not available for ephemeral agents" }), { status: 400 });
}
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}));
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByText("Runs"));
await waitFor(() => {
expect(screen.queryByText("Cache hit ratio")).not.toBeInTheDocument();
});
});
const navigateToRuns = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();

View File

@@ -96,6 +96,9 @@ describe("TaskTokenStatsPanel", () => {
expect(screen.getByText("210")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("1,860")).toBeInTheDocument();
expect(screen.getByText("Cache hit ratio:")).toBeInTheDocument();
expect(screen.getByText("14.9%")).toBeInTheDocument();
expect(screen.getByText("(read 210 / write 15 / input 1,200)")).toBeInTheDocument();
const firstUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T09:00:00.000Z");
const lastUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T10:15:00.000Z");
@@ -104,6 +107,28 @@ describe("TaskTokenStatsPanel", () => {
expect(lastUsedTime).toBeInTheDocument();
});
it("shows dash cache hit ratio when cache/input denominator is zero", () => {
render(
<TaskTokenStatsPanel
loading={false}
task={makeTask()}
tokenUsage={{
inputTokens: 0,
outputTokens: 12,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 12,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
}}
/>,
);
expect(screen.getByText("Cache hit ratio:")).toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("(read 0 / write 0 / input 0)")).toBeInTheDocument();
});
it("gracefully handles logs without timing patterns", () => {
render(
<TaskTokenStatsPanel

View File

@@ -84,12 +84,7 @@ describe("accumulateSessionTokenUsage", () => {
it("emits token-cache-metrics log when executor persists non-zero delta", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const store = createStore(undefined);
const executor = Object.create(TaskExecutor.prototype) as TaskExecutor & {
store: TaskStore;
tokenUsageBaselines: Map<string, { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number }>;
activeSessions: Map<string, { session: unknown }>;
persistTokenUsage: (taskId: string, session?: unknown) => Promise<void>;
};
const executor = Object.create(TaskExecutor.prototype) as any;
executor.store = store;
executor.tokenUsageBaselines = new Map();
executor.activeSessions = new Map();