FN-7150: show ephemeral agent token counts
Surface task-derived token usage for ephemeral agents in dashboard agent views. - Attribute task token totals across assigned, source, and checkout agent links. - Backfill zero-valued agent list token totals from linked task usage. - Allow Agent Detail token usage windows for ephemeral/task-worker agents and cover the routes with tests. - Document the dashboard behavior and add a patch changeset. Files changed: .changeset/fn-7150-ephemeral-agent-token-counts.md | 7 ++ docs/dashboard-guide.md | 1 + .../core/src/__tests__/agent-token-usage.test.ts | 99 ++++++++++++++++++++-- packages/core/src/__tests__/team-analytics.test.ts | 48 +++++++++++ packages/core/src/agent-token-usage.ts | 83 +++++++++++++++++- packages/core/src/index.ts | 4 +- .../__tests__/AgentTokenStatsPanel.test.tsx | 21 +++++ .../src/__tests__/routes-agent-token-usage.test.ts | 20 ++++- .../dashboard/src/__tests__/routes-agents.test.ts | 87 +++++++++++++++++++ .../src/routes/register-agent-core-routes.ts | 38 ++++++++- .../src/routes/register-agent-runtime-routes.ts | 5 +- 11 files changed, 395 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7150 Fusion-Task-Lineage: ce2661ce-5ba5-43b0-aa5c-7fddee90a915 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7150-ephemeral-agent-token-counts.md
Normal file
7
.changeset/fn-7150-ephemeral-agent-token-counts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Ephemeral/task-worker agents now show their token usage on the dashboard.
|
||||
category: fix
|
||||
dev: Derives zero/absent per-agent dashboard totals from task token usage and allows ephemeral Agent Detail token windows.
|
||||
@@ -712,6 +712,7 @@ Navigation:
|
||||
Features:
|
||||
- Switch between **List**, **Board**, and **Org chart** layouts
|
||||
- Filter by role/state, include/exclude system agents, and inspect health/status
|
||||
- **Token Usage by Agent** includes task-derived token counts for ephemeral/task-worker system agents when system agents are shown, matching Agent detail and Command Center Team token surfaces.
|
||||
- Agent list cards show the configured **Model** or plugin **Runtime** for each agent, falling back to **Auto** when no override is set
|
||||
<!-- FNXC:AgentTaskStateDrift 2026-06-27-16:46: Agent task badges include the linked task column so parked `triage`/`todo` ownership from the FN-7138 invariant is not misread as execution drift. -->
|
||||
- Agent list, live-agent, and detail task badges show the linked task ID with its current column when the task is non-terminal (for example `FN-6902 · Triage` or `FN-6902 · In Progress`). Terminal linked tasks are omitted, and unresolved column lookups render an explicit `Unresolved task` suffix so missing or deleted task links are not mistaken for healthy parked work.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import { aggregateAgentTokenUsage } from "../agent-token-usage.js";
|
||||
import { aggregateAgentTokenUsage, aggregateTaskTokenTotalsByAgentLink } from "../agent-token-usage.js";
|
||||
import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("aggregateAgentTokenUsage", () => {
|
||||
@@ -25,10 +25,99 @@ describe("aggregateAgentTokenUsage", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for ephemeral agents", async () => {
|
||||
const ephemeral = await agentStore.createAgent({ name: "temp", role: "executor", reportsTo: "FN-1", metadata: { type: "spawned" } });
|
||||
const result = await aggregateAgentTokenUsage({ taskStore: harness.store(), agentStore, agentId: ephemeral.id });
|
||||
expect(result).toBeNull();
|
||||
it("returns zero windows for an ephemeral task-worker with no token-bearing tasks", async () => {
|
||||
const ephemeral = await agentStore.createAgent({ name: "executor-FN-0000", role: "executor", metadata: { agentKind: "task-worker" } });
|
||||
await harness.store().createTask({
|
||||
description: "task without token usage",
|
||||
assignedAgentId: ephemeral.id,
|
||||
});
|
||||
|
||||
const result = await aggregateAgentTokenUsage({
|
||||
taskStore: harness.store(),
|
||||
agentStore,
|
||||
agentId: ephemeral.id,
|
||||
now: new Date("2026-05-13T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.allTime).toMatchObject({ totalInputTokens: 0, totalCachedTokens: 0, totalCacheWriteTokens: 0, totalOutputTokens: 0, nTasks: 0 });
|
||||
expect(result?.last24h).toMatchObject({ totalInputTokens: 0, totalCachedTokens: 0, totalCacheWriteTokens: 0, totalOutputTokens: 0, nTasks: 0 });
|
||||
});
|
||||
|
||||
it("aggregates task-derived usage for ephemeral task-worker agents", async () => {
|
||||
const ephemeral = await agentStore.createAgent({ name: "executor-FN-1234", role: "executor", metadata: { agentKind: "task-worker" } });
|
||||
await harness.store().createTask({
|
||||
description: "ephemeral worker task",
|
||||
assignedAgentId: ephemeral.id,
|
||||
tokenUsage: {
|
||||
inputTokens: 75,
|
||||
outputTokens: 25,
|
||||
cachedTokens: 10,
|
||||
cacheWriteTokens: 5,
|
||||
totalTokens: 115,
|
||||
firstUsedAt: "2026-05-13T09:00:00.000Z",
|
||||
lastUsedAt: "2026-05-13T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await aggregateAgentTokenUsage({
|
||||
taskStore: harness.store(),
|
||||
agentStore,
|
||||
agentId: ephemeral.id,
|
||||
now: new Date("2026-05-13T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.allTime).toMatchObject({ totalInputTokens: 75, totalCachedTokens: 10, totalCacheWriteTokens: 5, totalOutputTokens: 25, nTasks: 1 });
|
||||
expect(result?.last24h).toMatchObject({ totalInputTokens: 75, totalCachedTokens: 10, totalCacheWriteTokens: 5, totalOutputTokens: 25, nTasks: 1 });
|
||||
});
|
||||
|
||||
it("aggregates task-derived totals by assigned, source, and checkout agent links without double-counting same-agent links", async () => {
|
||||
const agent = await agentStore.createAgent({ name: "executor-FN-links", role: "executor", metadata: { agentKind: "task-worker" } });
|
||||
await harness.store().createTask({
|
||||
description: "source-linked token usage",
|
||||
source: { sourceType: "agent_heartbeat", sourceAgentId: agent.id },
|
||||
tokenUsage: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 7,
|
||||
cachedTokens: 3,
|
||||
cacheWriteTokens: 1,
|
||||
totalTokens: 41,
|
||||
firstUsedAt: "2026-05-13T09:00:00.000Z",
|
||||
lastUsedAt: "2026-05-13T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const checkedTask = await harness.store().createTask({
|
||||
description: "checkout-linked token usage",
|
||||
tokenUsage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 27,
|
||||
firstUsedAt: "2026-05-13T09:00:00.000Z",
|
||||
lastUsedAt: "2026-05-13T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
await harness.store().updateTask(checkedTask.id, { checkedOutBy: agent.id });
|
||||
await harness.store().createTask({
|
||||
description: "same agent appears in multiple task links",
|
||||
assignedAgentId: agent.id,
|
||||
source: { sourceType: "agent_heartbeat", sourceAgentId: agent.id },
|
||||
tokenUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 14,
|
||||
firstUsedAt: "2026-05-13T09:00:00.000Z",
|
||||
lastUsedAt: "2026-05-13T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const totals = aggregateTaskTokenTotalsByAgentLink(harness.store().getDatabase()).get(agent.id);
|
||||
|
||||
expect(totals).toMatchObject({ inputTokens: 60, cachedTokens: 5, cacheWriteTokens: 1, outputTokens: 16, totalTokens: 82, nTasks: 3 });
|
||||
});
|
||||
|
||||
it("aggregates usage across windows", async () => {
|
||||
|
||||
@@ -206,6 +206,54 @@ describe("team-analytics", () => {
|
||||
expect(result.agents[0].tasksInProgress).toBe(1);
|
||||
});
|
||||
|
||||
it("includes ephemeral executor agents in per-agent token totals", () => {
|
||||
insertAgent(db, "agent-durable", "Durable", "executor", "idle");
|
||||
insertAgent(db, "agent-ephemeral", "executor-FN-1234", "executor", "running");
|
||||
insertTask(db, {
|
||||
id: "durable-tokens",
|
||||
agentId: "agent-durable",
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
cachedTokens: 5,
|
||||
cacheWriteTokens: 1,
|
||||
totalTokens: 56,
|
||||
tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
insertTask(db, {
|
||||
id: "ephemeral-tokens",
|
||||
agentId: "agent-ephemeral",
|
||||
inputTokens: 120,
|
||||
outputTokens: 45,
|
||||
cachedTokens: 10,
|
||||
cacheWriteTokens: 5,
|
||||
totalTokens: 180,
|
||||
tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
insertTask(db, {
|
||||
id: "ephemeral-no-usage",
|
||||
agentId: "agent-ephemeral",
|
||||
tokenUsageLastUsedAt: null,
|
||||
});
|
||||
|
||||
const result = aggregateTeamAnalytics(db, {});
|
||||
const byAgent = new Map(result.agents.map((agent) => [agent.agentId, agent]));
|
||||
|
||||
expect(byAgent.get("agent-ephemeral")).toMatchObject({
|
||||
agentName: "executor-FN-1234",
|
||||
role: "executor",
|
||||
state: "running",
|
||||
});
|
||||
expect(byAgent.get("agent-ephemeral")?.tokens).toMatchObject({
|
||||
inputTokens: 120,
|
||||
outputTokens: 45,
|
||||
cachedTokens: 10,
|
||||
cacheWriteTokens: 5,
|
||||
totalTokens: 180,
|
||||
nTasks: 1,
|
||||
});
|
||||
expect(result.totals.tokens.totalTokens).toBe(236);
|
||||
});
|
||||
|
||||
it("keeps a safe row for a task whose agent row was deleted", () => {
|
||||
insertTask(db, {
|
||||
id: "orphan",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentStore } from "./agent-store.js";
|
||||
import type { Database } from "./db.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { isEphemeralAgent, type AgentRole } from "./types.js";
|
||||
import type { AgentRole } from "./types.js";
|
||||
|
||||
export interface AgentTokenUsageWindowSummary {
|
||||
totalInputTokens: number;
|
||||
@@ -19,6 +20,80 @@ export interface AgentTokenUsageSummary {
|
||||
allTime: AgentTokenUsageWindowSummary;
|
||||
}
|
||||
|
||||
export interface AgentTaskTokenTotals {
|
||||
inputTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
nTasks: number;
|
||||
}
|
||||
|
||||
interface TaskTokenLinkRow {
|
||||
taskId: string;
|
||||
assignedAgentId: string | null;
|
||||
sourceAgentId: string | null;
|
||||
checkedOutBy: string | null;
|
||||
inputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
cacheWriteTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
}
|
||||
|
||||
export function aggregateTaskTokenTotalsByAgentLink(db: Database): Map<string, AgentTaskTokenTotals> {
|
||||
/*
|
||||
FNXC:AgentTokenUsage 2026-06-27-23:06:
|
||||
List-row token totals must use the same assigned/source/checkout attribution as Agent Detail so ephemeral task-worker agents do not report zero when they only sourced or checked out a task.
|
||||
*/
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
id AS taskId,
|
||||
assignedAgentId,
|
||||
sourceAgentId,
|
||||
checkedOutBy,
|
||||
tokenUsageInputTokens AS inputTokens,
|
||||
tokenUsageCachedTokens AS cachedTokens,
|
||||
tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
||||
tokenUsageOutputTokens AS outputTokens,
|
||||
tokenUsageTotalTokens AS totalTokens
|
||||
FROM tasks
|
||||
WHERE tokenUsageInputTokens IS NOT NULL
|
||||
OR tokenUsageCachedTokens IS NOT NULL
|
||||
OR tokenUsageCacheWriteTokens IS NOT NULL
|
||||
OR tokenUsageOutputTokens IS NOT NULL
|
||||
OR tokenUsageTotalTokens IS NOT NULL
|
||||
`).all() as TaskTokenLinkRow[];
|
||||
|
||||
const totalsByAgentId = new Map<string, AgentTaskTokenTotals>();
|
||||
for (const row of rows) {
|
||||
const agentIds = new Set([row.assignedAgentId, row.sourceAgentId, row.checkedOutBy].filter((value): value is string => Boolean(value)));
|
||||
for (const agentId of agentIds) {
|
||||
const existing = totalsByAgentId.get(agentId) ?? createTaskTokenTotals();
|
||||
existing.inputTokens += row.inputTokens ?? 0;
|
||||
existing.cachedTokens += row.cachedTokens ?? 0;
|
||||
existing.cacheWriteTokens += row.cacheWriteTokens ?? 0;
|
||||
existing.outputTokens += row.outputTokens ?? 0;
|
||||
existing.totalTokens += row.totalTokens ?? (row.inputTokens ?? 0) + (row.cachedTokens ?? 0) + (row.cacheWriteTokens ?? 0) + (row.outputTokens ?? 0);
|
||||
existing.nTasks += 1;
|
||||
totalsByAgentId.set(agentId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return totalsByAgentId;
|
||||
}
|
||||
|
||||
function createTaskTokenTotals(): AgentTaskTokenTotals {
|
||||
return {
|
||||
inputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
nTasks: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function aggregateAgentTokenUsage({
|
||||
taskStore,
|
||||
agentStore,
|
||||
@@ -31,10 +106,14 @@ export async function aggregateAgentTokenUsage({
|
||||
now?: Date;
|
||||
}): Promise<AgentTokenUsageSummary | null> {
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent || isEphemeralAgent(agent)) {
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentTokenUsage 2026-06-27-19:10:
|
||||
Ephemeral/task-worker agents must surface task-derived token usage because their cumulative agent token fields are never accumulated by the durable-agent heartbeat path.
|
||||
*/
|
||||
const tasks = await taskStore.listTasks({ slim: true, includeArchived: true });
|
||||
const nowMs = now.getTime();
|
||||
const last24hMs = nowMs - (24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -577,8 +577,8 @@ export {
|
||||
} from "./duplicate-intake.js";
|
||||
export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary.js";
|
||||
export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js";
|
||||
export { aggregateAgentTokenUsage } from "./agent-token-usage.js";
|
||||
export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js";
|
||||
export { aggregateAgentTokenUsage, aggregateTaskTokenTotalsByAgentLink } from "./agent-token-usage.js";
|
||||
export type { AgentTaskTokenTotals, AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js";
|
||||
export {
|
||||
emitUsageEvent,
|
||||
queryUsageEvents,
|
||||
|
||||
@@ -52,6 +52,27 @@ describe("AgentTokenStatsPanel", () => {
|
||||
expect(within(rows[2]).getByText("Beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders non-zero task-derived totals for an ephemeral agent row", () => {
|
||||
render(
|
||||
<AgentTokenStatsPanel
|
||||
agents={[
|
||||
makeAgent({
|
||||
id: "agent-ephemeral",
|
||||
name: "executor-FN-1234",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
totalInputTokens: 120,
|
||||
totalOutputTokens: 45,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("row", { name: /executor-FN-1234/i });
|
||||
expect(within(row).getByText("120")).toBeInTheDocument();
|
||||
expect(within(row).getByText("45")).toBeInTheDocument();
|
||||
expect(within(row).getByText("165")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats missing token fields as zero and shows empty state when there is no usage", () => {
|
||||
render(
|
||||
<AgentTokenStatsPanel
|
||||
|
||||
@@ -86,10 +86,22 @@ describe("GET /api/agents/:id/token-usage", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for ephemeral agents", async () => {
|
||||
it("returns summary for ephemeral agents", async () => {
|
||||
mockGetAgent.mockResolvedValueOnce({ id: "executor-FN-1234", role: "executor", name: "executor-FN-1234", metadata: { agentKind: "task-worker" } });
|
||||
mockIsEphemeralAgent.mockReturnValueOnce(true);
|
||||
const res = await get(app, "/api/agents/agent-001/token-usage");
|
||||
expect(res.status).toBe(400);
|
||||
expect((res.body as any).error).toContain("ephemeral");
|
||||
mockAggregateAgentTokenUsage.mockResolvedValueOnce({
|
||||
agentId: "executor-FN-1234",
|
||||
role: "executor",
|
||||
last24h: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 },
|
||||
last7d: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 },
|
||||
allTime: { totalInputTokens: 120, totalCachedTokens: 20, totalCacheWriteTokens: 5, totalOutputTokens: 40, nTasks: 1, hitRatio: 20 / 140 },
|
||||
});
|
||||
|
||||
const res = await get(app, "/api/agents/executor-FN-1234/token-usage");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).agentId).toBe("executor-FN-1234");
|
||||
expect((res.body as any).allTime.totalInputTokens).toBe(120);
|
||||
expect(mockAggregateAgentTokenUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: "executor-FN-1234" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2892,6 +2892,93 @@ describe("Agent stale task-link sanitization", () => {
|
||||
expect(listed.pendingApprovalCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/agents derives zero agent token totals from assigned, source, and checkout task usage without overwriting durable totals", async () => {
|
||||
const store = new CoreTaskStore(tempDir);
|
||||
await store.init();
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const durable = await agentStore.createAgent({
|
||||
name: "Durable Tokens",
|
||||
role: "executor",
|
||||
});
|
||||
await agentStore.updateAgent(durable.id, {
|
||||
totalInputTokens: 900,
|
||||
totalOutputTokens: 100,
|
||||
});
|
||||
const ephemeral = await agentStore.createAgent({
|
||||
name: "executor-FN-1234",
|
||||
role: "executor",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
});
|
||||
await store.createTask({
|
||||
description: "durable task should not replace stored cumulative totals",
|
||||
assignedAgentId: durable.id,
|
||||
tokenUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 2,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 3,
|
||||
firstUsedAt: "2026-06-27T18:00:00.000Z",
|
||||
lastUsedAt: "2026-06-27T18:05:00.000Z",
|
||||
},
|
||||
});
|
||||
await store.createTask({
|
||||
description: "ephemeral task derives listing totals from assignment",
|
||||
assignedAgentId: ephemeral.id,
|
||||
tokenUsage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 45,
|
||||
cachedTokens: 10,
|
||||
cacheWriteTokens: 5,
|
||||
totalTokens: 180,
|
||||
firstUsedAt: "2026-06-27T18:00:00.000Z",
|
||||
lastUsedAt: "2026-06-27T18:05:00.000Z",
|
||||
},
|
||||
});
|
||||
await store.createTask({
|
||||
description: "ephemeral task derives listing totals from source link",
|
||||
source: { sourceType: "agent_heartbeat", sourceAgentId: ephemeral.id },
|
||||
tokenUsage: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 15,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 45,
|
||||
firstUsedAt: "2026-06-27T18:00:00.000Z",
|
||||
lastUsedAt: "2026-06-27T18:05:00.000Z",
|
||||
},
|
||||
});
|
||||
const checkedTask = await store.createTask({
|
||||
description: "ephemeral task derives listing totals from checkout link",
|
||||
tokenUsage: {
|
||||
inputTokens: 50,
|
||||
outputTokens: 20,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 70,
|
||||
firstUsedAt: "2026-06-27T18:00:00.000Z",
|
||||
lastUsedAt: "2026-06-27T18:05:00.000Z",
|
||||
},
|
||||
});
|
||||
await store.updateTask(checkedTask.id, { checkedOutBy: ephemeral.id });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await GET(app, "/api/agents?includeEphemeral=true");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const listedDurable = agents.find((a: { id: string }) => a.id === durable.id);
|
||||
const listedEphemeral = agents.find((a: { id: string }) => a.id === ephemeral.id);
|
||||
expect(listedDurable).toMatchObject({ totalInputTokens: 900, totalOutputTokens: 100 });
|
||||
expect(listedEphemeral).toMatchObject({ totalInputTokens: 200, totalOutputTokens: 80 });
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => {
|
||||
const app = buildAgentApp();
|
||||
await createPendingApproval("agent-missing");
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissi
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||
aggregateTaskTokenTotalsByAgentLink,
|
||||
getDefaultHeartbeatProcedurePath,
|
||||
isAgentPermissionPolicyPresetId,
|
||||
isEphemeralAgent,
|
||||
@@ -79,6 +80,40 @@ function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent
|
||||
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
|
||||
}
|
||||
|
||||
function withTaskDerivedTokenTotals<T extends Agent>(agents: T[], scopedStore: TaskStore): T[] {
|
||||
try {
|
||||
const tokenTotalsByAgentId = aggregateTaskTokenTotalsByAgentLink(scopedStore.getDatabase());
|
||||
|
||||
return agents.map((agent) => {
|
||||
const storedInputTokens = agent.totalInputTokens ?? 0;
|
||||
const storedOutputTokens = agent.totalOutputTokens ?? 0;
|
||||
if (storedInputTokens > 0 || storedOutputTokens > 0) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
const taskTotals = tokenTotalsByAgentId.get(agent.id);
|
||||
if (!taskTotals || (taskTotals.inputTokens <= 0 && taskTotals.outputTokens <= 0)) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Agents 2026-06-27-19:16:
|
||||
The Agents listing derives zero/absent token totals from task links so ephemeral task-worker rows show real dashboard counts while durable agents keep their non-zero cumulative heartbeat totals.
|
||||
|
||||
FNXC:Agents 2026-06-27-23:06:
|
||||
Use the shared assigned/source/checkout token attribution so list rows match Agent Detail for task-worker agents that only sourced or checked out a task.
|
||||
*/
|
||||
return {
|
||||
...agent,
|
||||
totalInputTokens: taskTotals.inputTokens,
|
||||
totalOutputTokens: taskTotals.outputTokens,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return agents;
|
||||
}
|
||||
}
|
||||
|
||||
function withPendingApprovalCounts<T extends Agent>(agents: T[], scopedStore: TaskStore): Array<T & { pendingApprovalCount: number }> {
|
||||
try {
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
@@ -125,7 +160,8 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
res.json(withPendingApprovalCounts(sanitizedAgents, scopedStore));
|
||||
const agentsWithTokenTotals = withTaskDerivedTokenTotals(sanitizedAgents, scopedStore);
|
||||
res.json(withPendingApprovalCounts(agentsWithTokenTotals, scopedStore));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -680,7 +680,7 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
||||
router.get("/agents/:id/token-usage", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore, aggregateAgentTokenUsage, isEphemeralAgent } = await import("@fusion/core");
|
||||
const { AgentStore, aggregateAgentTokenUsage } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
@@ -688,9 +688,6 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
if (isEphemeralAgent(agent)) {
|
||||
throw badRequest("Token usage is not available for ephemeral agents");
|
||||
}
|
||||
|
||||
const summary = await aggregateAgentTokenUsage({
|
||||
taskStore: scopedStore,
|
||||
|
||||
Reference in New Issue
Block a user