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:
@@ -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" : ""}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user