Files
fusion/packages/engine/src/__tests__/executor-token-usage.test.ts
gsxdsm 41f387b219 FN-8364: track task token usage from session deltas
Track only task-local deltas from cumulative session usage.

- Capture and reset baselines for reused, resumed, and heartbeat sessions
- Route executor prompt accounting through the shared per-session delta seam
- Cover token accounting behavior and document the analytics correction

Files changed:
 .changeset/fn-8364-session-token-deltas.md         |  7 +++
 docs/storage.md                                    |  2 +-
 .../src/__tests__/executor-token-usage.test.ts     | 48 +++++++++++++++++++-
 .../src/__tests__/heartbeat-executor.test.ts       | 42 +++++++++++++++++
 .../src/__tests__/session-token-usage.test.ts      | 53 +++++++++++++++++++++-
 packages/engine/src/agent-heartbeat.ts             | 10 +++-
 packages/engine/src/executor.ts                    | 44 +++++++++++-------
 packages/engine/src/session-token-usage.ts         | 20 ++++++++
 8 files changed, 205 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-8364

Fusion-Task-Lineage: 2b16a581-0d58-4c91-8903-6ce368b30a7c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-19 15:38:31 -07:00

82 lines
3.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
function createTokenStore(): TaskStore & { task: Task; updateTask: ReturnType<typeof vi.fn> } {
const task = { id: "FN-1", title: "Token accounting", tokenUsage: undefined } as Task;
const updateTask = vi.fn(async (_taskId: string, patch: Partial<Task>) => Object.assign(task, patch));
return {
task,
getTask: vi.fn(async () => task),
updateTask,
getSettingsByScope: vi.fn(async () => ({ project: {}, global: {} })),
} as unknown as TaskStore & { task: Task; updateTask: ReturnType<typeof vi.fn> };
}
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,
});
});
it("starts a resumed central session at its task baseline and persists each prompt delta once", async () => {
const store = createTokenStore();
const executor = Object.create(TaskExecutor.prototype) as any;
executor.store = store;
executor.tokenUsageBaselines = new Map();
executor.activeSessions = new Map();
executor.currentRunContexts = new Map();
const stats = { input: 1_000, output: 400, cacheRead: 50, cacheWrite: 10, total: 1_460 };
const session = {
model: { provider: "mock", id: "resumed" },
getSessionStats: () => ({ tokens: stats }),
};
await executor.captureExecutorTokenUsageBaseline("FN-1", session);
await executor.persistTokenUsage("FN-1", session);
expect(store.updateTask).not.toHaveBeenCalled();
Object.assign(stats, { input: 1_020, output: 410, cacheRead: 54, cacheWrite: 12, total: 1_496 });
// Prompt-path and finalization both use the same writer/baseline; only the first sees this delta.
await executor.persistTokenUsage("FN-1", session);
await executor.persistTokenUsage("FN-1", session);
expect(store.task.tokenUsage).toMatchObject({
inputTokens: 20,
outputTokens: 10,
cachedTokens: 4,
cacheWriteTokens: 2,
totalTokens: 36,
});
expect(store.task.tokenUsage?.perModel?.reduce((sum, bucket) => sum + bucket.totalTokens, 0)).toBe(36);
expect(store.updateTask).toHaveBeenCalledTimes(1);
});
});