fix(FN-4389): complete missing token usage parity and dashboard split
Fusion-Task-Id: FN-4389 Fusion-Task-Lineage: 366f9d57-c476-448f-96c0-6e753fc8b090
This commit is contained in:
9
.changeset/fn-4389-token-usage-semantics.md
Normal file
9
.changeset/fn-4389-token-usage-semantics.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Canonicalize task token usage semantics across heartbeat and executor paths by treating `cachedTokens` as cache-read only, storing cache writes in new `cacheWriteTokens`, and preserving raw `inputTokens`.
|
||||
|
||||
Dashboard token stats now render separate `Cache read` and `Cache write` values.
|
||||
|
||||
Historical rows created before this fix may still contain mixed cache-read+cache-write values inside `cachedTokens`; existing data is not backfilled.
|
||||
@@ -1830,13 +1830,14 @@ function RunsTab({
|
||||
const activeRuns = sortedRuns.filter(r => r.status === "active");
|
||||
const completedRuns = sortedRuns.filter(r => r.status !== "active");
|
||||
|
||||
const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number } | undefined) => {
|
||||
const renderUsage = (usage: { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens?: number } | undefined) => {
|
||||
if (!usage) return null;
|
||||
return (
|
||||
<div className="run-usage">
|
||||
<span>Input: {usage.inputTokens.toLocaleString()}</span>
|
||||
<span>Output: {usage.outputTokens.toLocaleString()}</span>
|
||||
{usage.cachedTokens > 0 && <span>Cached: {usage.cachedTokens.toLocaleString()}</span>}
|
||||
{usage.cachedTokens > 0 && <span>Cache read: {usage.cachedTokens.toLocaleString()}</span>}
|
||||
{(usage.cacheWriteTokens ?? 0) > 0 && <span>Cache write: {(usage.cacheWriteTokens ?? 0).toLocaleString()}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -253,9 +253,13 @@ export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStat
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.outputTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Cached</span>
|
||||
<span className="task-token-stats-panel__label">Cache read</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cachedTokens)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Cache write</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cacheWriteTokens ?? 0)}</span>
|
||||
</div>
|
||||
<div className="task-token-stats-panel__metric" role="listitem">
|
||||
<span className="task-token-stats-panel__label">Total</span>
|
||||
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
|
||||
|
||||
@@ -335,6 +335,7 @@ describe("AppModals", () => {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
cachedTokens: 100,
|
||||
cacheWriteTokens: 25,
|
||||
totalTokens: 1600,
|
||||
firstUsedAt: "2026-04-25T12:05:00.000Z",
|
||||
lastUsedAt: "2026-04-25T12:10:00.000Z",
|
||||
|
||||
@@ -1607,6 +1607,7 @@ describe("TaskDetailModal", () => {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
cachedTokens: 210,
|
||||
cacheWriteTokens: 15,
|
||||
totalTokens: 1860,
|
||||
firstUsedAt: "2026-04-24T09:00:00.000Z",
|
||||
lastUsedAt: "2026-04-24T10:15:00.000Z",
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("TaskTokenStatsPanel", () => {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 450,
|
||||
cachedTokens: 210,
|
||||
cacheWriteTokens: 15,
|
||||
totalTokens: 1860,
|
||||
firstUsedAt: "2026-04-24T09:00:00.000Z",
|
||||
lastUsedAt: "2026-04-24T10:15:00.000Z",
|
||||
@@ -87,11 +88,13 @@ describe("TaskTokenStatsPanel", () => {
|
||||
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cached")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cache read")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cache write")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,200")).toBeInTheDocument();
|
||||
expect(screen.getByText("450")).toBeInTheDocument();
|
||||
expect(screen.getByText("210")).toBeInTheDocument();
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,860")).toBeInTheDocument();
|
||||
|
||||
const firstUsedTime = screen.getByText((_, element) => element?.tagName === "TIME" && element.getAttribute("datetime") === "2026-04-24T09:00:00.000Z");
|
||||
|
||||
35
packages/engine/src/__tests__/executor-token-usage.test.ts
Normal file
35
packages/engine/src/__tests__/executor-token-usage.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
|
||||
describe("executor token usage extraction", () => {
|
||||
it("uses canonical cache-read/cache-write split (FN-4389)", async () => {
|
||||
const executor = Object.create(TaskExecutor.prototype) as TaskExecutor;
|
||||
const methods = executor as unknown as {
|
||||
extractSessionTokenUsage: (session: unknown) => Promise<{ inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number } | undefined>;
|
||||
accumulateTokenUsage: (existing: undefined, delta: { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number }) => { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number };
|
||||
};
|
||||
|
||||
const delta = await methods.extractSessionTokenUsage({
|
||||
getSessionStats: () => ({
|
||||
tokens: { input: 1000, output: 500, cacheRead: 800, cacheWrite: 200, total: 2500 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(delta).toMatchObject({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cachedTokens: 800,
|
||||
cacheWriteTokens: 200,
|
||||
totalTokens: 2500,
|
||||
});
|
||||
|
||||
const merged = delta ? methods.accumulateTokenUsage(undefined, delta) : undefined;
|
||||
expect(merged).toMatchObject({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cachedTokens: 800,
|
||||
cacheWriteTokens: 200,
|
||||
totalTokens: 2500,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { accumulateSessionTokenUsage } from "../session-token-usage.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
|
||||
interface MockSessionStats {
|
||||
tokens?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
||||
@@ -124,4 +125,37 @@ describe("accumulateSessionTokenUsage", () => {
|
||||
|
||||
await expect(accumulateSessionTokenUsage(store, "FN-1", session)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 },
|
||||
{ input: 1000, output: 500, cacheRead: 800, cacheWrite: 200, total: 2500 },
|
||||
])("FN-4389 canonical semantic parity for stats %#", async (tokens) => {
|
||||
const heartbeatStore = createStore(undefined);
|
||||
const heartbeatSession = createSession({ tokens });
|
||||
await accumulateSessionTokenUsage(heartbeatStore, "FN-1", heartbeatSession);
|
||||
const heartbeatUsage = (heartbeatStore.updateTask.mock.calls[0]?.[1] as { tokenUsage?: Task["tokenUsage"] })?.tokenUsage;
|
||||
|
||||
const executor = Object.create(TaskExecutor.prototype) as TaskExecutor;
|
||||
const extract = (executor as unknown as {
|
||||
extractSessionTokenUsage: (session: unknown) => Promise<{ inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number } | undefined>;
|
||||
accumulateTokenUsage: (existing: Task["tokenUsage"], delta: { inputTokens: number; outputTokens: number; cachedTokens: number; cacheWriteTokens: number; totalTokens: number }) => Task["tokenUsage"];
|
||||
});
|
||||
const delta = await extract.extractSessionTokenUsage({ getSessionStats: () => ({ tokens }) });
|
||||
const executorUsage = delta ? extract.accumulateTokenUsage(undefined, delta) : undefined;
|
||||
|
||||
expect(heartbeatUsage).toMatchObject({
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
cachedTokens: tokens.cacheRead,
|
||||
cacheWriteTokens: tokens.cacheWrite,
|
||||
totalTokens: tokens.total,
|
||||
});
|
||||
expect(executorUsage).toMatchObject({
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
cachedTokens: tokens.cacheRead,
|
||||
cacheWriteTokens: tokens.cacheWrite,
|
||||
totalTokens: tokens.total,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user