Files
elizaos--eliza/plugins/plugin-agent-orchestrator/__tests__/unit/trace-usage-rollup.test.ts
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

328 lines
11 KiB
TypeScript

/**
* Per-task trace usage roll-up (#13775 item 5). Drives the service's real
* getTraceUsage against on-disk child-trajectory files ingested as artifacts,
* asserting it sums the file-recorder metrics grouped by traceId — separate
* from the ACP-frame getUsage surface — and marks the roll-up partial when an
* unreadable/corrupt file prevents a complete accounting. Uses the injectable
* InMemoryTaskStore (real store) and the real ingest path; no mock stands in
* for the code under test.
*/
import { randomUUID } from "node:crypto";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as core from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// getTraceUsage delegates to core's rollUpTrajectoryUsage. In an isolated
// worktree the plugin resolves core's PREBUILT dist, which is stale until
// turbo rebuilds it (CI does this before plugin tests); a namespace import
// yields `undefined` for a not-yet-built export rather than a load error, so
// guard on it and skip a stale-dist local run instead of red-ing. Same
// stale-dist guard rationale as child-trajectory-ingest.test.ts.
const describeCore =
typeof (core as { rollUpTrajectoryUsage?: unknown }).rollUpTrajectoryUsage ===
"function"
? describe
: describe.skip;
import { OrchestratorTaskService } from "../../src/services/orchestrator-task-service.js";
import { InMemoryTaskStore } from "../../src/services/orchestrator-task-store.js";
import type { OrchestratorTaskSession } from "../../src/services/orchestrator-task-types.js";
let stateDir: string;
const prevStateDir = process.env.ELIZA_STATE_DIR;
function makeRuntime() {
return {
agentId: "00000000-0000-4000-8000-000000000001",
adapter: undefined,
databaseAdapter: undefined,
logger: { debug() {}, info() {}, warn() {}, error() {} },
getSetting: () => undefined,
reportError: () => {},
} as unknown as Parameters<typeof OrchestratorTaskService>[0];
}
async function seedTaskWithSession(
store: InMemoryTaskStore,
session: Partial<OrchestratorTaskSession>,
): Promise<{ taskId: string; sessionId: string }> {
const doc = await store.createTask({ title: "t", goal: "do the thing" });
const taskId = doc.task.id;
const sessionId = "sess-1";
const now = new Date().toISOString();
await store.addSession({
id: "row-1",
taskId,
sessionId,
framework: "elizaos",
label: "worker",
originalTask: "do the thing",
workdir: "/tmp/wd",
status: "completed",
decisionCount: 0,
autoResolvedCount: 0,
registeredAt: Date.now(),
lastActivityAt: Date.now(),
idleCheckCount: 0,
taskDelivered: true,
lastSeenDecisionIndex: 0,
spawnedAt: Date.now(),
retryCount: 0,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cacheTokens: 0,
costUsd: 0,
usageState: "unavailable",
metadata: {},
createdAt: now,
updatedAt: now,
...session,
});
return { taskId, sessionId };
}
function trajectoryJson(traceId: string, prompt: number, cost: number): string {
return JSON.stringify({
trajectoryId: `tj-${prompt}`,
agentId: "child-agent",
traceId,
rootMessage: { id: "m", text: "hi" },
startedAt: 0,
status: "finished",
stages: [],
metrics: {
totalLatencyMs: 0,
totalPromptTokens: prompt,
totalCompletionTokens: Math.floor(prompt / 5),
totalCacheReadTokens: 0,
totalCacheCreationTokens: 0,
totalCostUsd: cost,
plannerIterations: 0,
toolCallsExecuted: 0,
toolCallFailures: 0,
toolSearchCount: 0,
evaluatorFailures: 0,
},
});
}
beforeEach(() => {
stateDir = mkdtempSync(join(tmpdir(), "orch-trace-usage-"));
process.env.ELIZA_STATE_DIR = stateDir;
});
afterEach(() => {
if (prevStateDir === undefined) delete process.env.ELIZA_STATE_DIR;
else process.env.ELIZA_STATE_DIR = prevStateDir;
});
async function ingest(
svc: OrchestratorTaskService,
taskId: string,
sessionId: string,
): Promise<string[]> {
return (
svc as unknown as {
ingestChildTrajectories: (t: string, s: string) => Promise<string[]>;
}
).ingestChildTrajectories(taskId, sessionId);
}
describeCore("getTraceUsage", () => {
it("sums the ingested child-trajectory metrics grouped by traceId", async () => {
const store = new InMemoryTaskStore();
const { taskId, sessionId } = await seedTaskWithSession(store, {
traceId: "trace-parent",
});
const svc = new OrchestratorTaskService(makeRuntime(), { store });
const dir = join(
stateDir,
"orchestrator",
"child-trajectories",
taskId,
"child-agent",
);
await mkdir(dir, { recursive: true });
writeFileSync(
join(dir, "tj-100.json"),
trajectoryJson("trace-parent", 100, 0.01),
);
writeFileSync(
join(dir, "tj-50.json"),
trajectoryJson("trace-parent", 50, 0.005),
);
await ingest(svc, taskId, sessionId);
const rollup = await svc.getTraceUsage(taskId);
expect(rollup).not.toBeNull();
expect(rollup?.readState).toBe("complete");
expect(rollup?.artifactErrors).toEqual([]);
expect(rollup?.artifactCount).toBe(2);
expect(rollup?.readableArtifactCount).toBe(2);
expect(rollup?.unreadableArtifactCount).toBe(0);
expect(rollup?.byTrace).toHaveLength(1);
expect(rollup?.byTrace[0].traceId).toBe("trace-parent");
expect(rollup?.promptTokens).toBe(150);
// completion = floor(100/5) + floor(50/5) = 20 + 10 = 30
expect(rollup?.completionTokens).toBe(30);
expect(rollup?.totalTokens).toBe(180);
expect(rollup?.trajectoryCount).toBe(2);
expect(rollup?.costUsd).toBeCloseTo(0.015, 6);
});
it("returns a partial roll-up when a corrupt artifact prevents complete usage accounting", async () => {
const store = new InMemoryTaskStore();
const { taskId, sessionId } = await seedTaskWithSession(store, {
traceId: "trace-parent",
});
const svc = new OrchestratorTaskService(makeRuntime(), { store });
const dir = join(
stateDir,
"orchestrator",
"child-trajectories",
taskId,
"child-agent",
);
await mkdir(dir, { recursive: true });
writeFileSync(
join(dir, "tj-good.json"),
trajectoryJson("trace-parent", 40, 0.004),
);
writeFileSync(join(dir, "tj-bad.json"), "{ this is not valid json");
await ingest(svc, taskId, sessionId);
const rollup = await svc.getTraceUsage(taskId);
expect(rollup?.promptTokens).toBe(40);
expect(rollup?.trajectoryCount).toBe(1);
expect(rollup?.costUsd).toBeCloseTo(0.004, 6);
expect(rollup?.readState).toBe("partial");
expect(rollup?.artifactCount).toBe(2);
expect(rollup?.readableArtifactCount).toBe(1);
expect(rollup?.unreadableArtifactCount).toBe(1);
expect(rollup?.artifactErrors).toEqual([
expect.objectContaining({
path: join(dir, "tj-bad.json"),
reason: "read_failed",
}),
]);
});
it("returns a partial roll-up when a trajectory artifact has no metrics block", async () => {
const store = new InMemoryTaskStore();
const { taskId, sessionId } = await seedTaskWithSession(store, {
traceId: "trace-parent",
});
const svc = new OrchestratorTaskService(makeRuntime(), { store });
const dir = join(
stateDir,
"orchestrator",
"child-trajectories",
taskId,
"child-agent",
);
await mkdir(dir, { recursive: true });
writeFileSync(
join(dir, "tj-good.json"),
trajectoryJson("trace-parent", 25, 0.0025),
);
writeFileSync(
join(dir, "tj-invalid.json"),
JSON.stringify({ trajectoryId: "tj-invalid", traceId: "trace-parent" }),
);
await ingest(svc, taskId, sessionId);
const rollup = await svc.getTraceUsage(taskId);
expect(rollup?.promptTokens).toBe(25);
expect(rollup?.readState).toBe("partial");
expect(rollup?.artifactCount).toBe(2);
expect(rollup?.readableArtifactCount).toBe(1);
expect(rollup?.unreadableArtifactCount).toBe(1);
expect(rollup?.artifactErrors).toEqual([
expect.objectContaining({
path: join(dir, "tj-invalid.json"),
reason: "invalid_trajectory",
}),
]);
});
it("returns an empty roll-up (not null) for a task with no trajectory artifacts", async () => {
const store = new InMemoryTaskStore();
const { taskId } = await seedTaskWithSession(store, {});
const svc = new OrchestratorTaskService(makeRuntime(), { store });
const rollup = await svc.getTraceUsage(taskId);
expect(rollup).not.toBeNull();
expect(rollup?.readState).toBe("complete");
expect(rollup?.artifactCount).toBe(0);
expect(rollup?.artifactErrors).toEqual([]);
expect(rollup?.byTrace).toEqual([]);
expect(rollup?.totalTokens).toBe(0);
expect(rollup?.trajectoryCount).toBe(0);
});
it("counts a trajectory file once even when duplicate artifact rows point at it (multi-session/retry rescan)", async () => {
const store = new InMemoryTaskStore();
const { taskId, sessionId } = await seedTaskWithSession(store, {
traceId: "trace-parent",
});
const svc = new OrchestratorTaskService(makeRuntime(), { store });
const dir = join(
stateDir,
"orchestrator",
"child-trajectories",
taskId,
"child-agent",
);
await mkdir(dir, { recursive: true });
const trajPath = join(dir, "tj-dup.json");
writeFileSync(trajPath, trajectoryJson("trace-parent", 80, 0.008));
// ingestChildTrajectories now path-dedupes at ingest time (#14110), so it
// attaches at most one row per file and a re-ingest is a no-op. getTraceUsage
// keeps its own path-dedupe to defend against duplicate rows that predate
// that fix (or a legacy multi-session rescan). Reproduce exactly that
// condition: ingest once for a genuine row, then attach a second row for the
// SAME file directly, the way a pre-dedupe rescan would have.
await ingest(svc, taskId, sessionId);
await store.addArtifact({
id: randomUUID(),
taskId,
sessionId,
artifactType: "trajectory",
title: "Sub-agent trajectory tj-dup (duplicate row)",
path: trajPath,
verificationStatus: "pending",
metadata: {},
createdAt: new Date().toISOString(),
});
const doc = await store.getTask(taskId);
const trajRows = (doc?.artifacts ?? []).filter(
(a) => a.artifactType === "trajectory",
);
expect(trajRows.length).toBeGreaterThan(1); // duplicate rows exist
const rollup = await svc.getTraceUsage(taskId);
// ...but the file's spend is counted exactly once.
expect(rollup?.readState).toBe("complete");
expect(rollup?.artifactCount).toBe(1);
expect(rollup?.readableArtifactCount).toBe(1);
expect(rollup?.promptTokens).toBe(80);
expect(rollup?.trajectoryCount).toBe(1);
expect(rollup?.costUsd).toBeCloseTo(0.008, 6);
});
it("returns null for an unknown task", async () => {
const store = new InMemoryTaskStore();
const svc = new OrchestratorTaskService(makeRuntime(), { store });
expect(await svc.getTraceUsage("nope")).toBeNull();
});
});