From cdab8be8980a7e7ea69596f5d5619ec8992d5f2d Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Tue, 8 Sep 2026 20:53:35 +0000 Subject: [PATCH 1/2] fix: aggregate OpenCode usage across completed steps --- src/cli.ts | 5 +- src/opencode-usage.ts | 69 ++++++++++++++ src/usage.ts | 46 +++++---- tests/headless.test.ts | 6 +- tests/opencode-streaming-usage.test.ts | 54 +++++++++++ tests/opencode-usage.test.ts | 124 +++++++++++++++++++++++++ 6 files changed, 286 insertions(+), 18 deletions(-) create mode 100644 src/opencode-usage.ts create mode 100644 tests/opencode-streaming-usage.test.ts create mode 100644 tests/opencode-usage.test.ts diff --git a/src/cli.ts b/src/cli.ts index b265e19..6eddd0c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,6 +97,7 @@ import { } from "./launch-lock.js"; import { forceKillWindowsProcessTree } from "./process-tree.js"; import { compactOversizedTraceLine } from "./relevant-trace.js"; +import { OpencodeUsageAccumulator } from "./opencode-usage.js"; import { handleRunCommand as handleRunCommandImpl } from "./run-commands.js"; import { handleCronCommand as handleCronCommandImpl, type CronCommand } from "./cron-commands.js"; import { runCronDaemon } from "./cron.js"; @@ -1769,6 +1770,7 @@ async function executeCommand( let traceBuffer = ""; let traceRowDiscarded = false; const relevantTrace: string[] = []; + const opencodeUsage = agent === "opencode" ? new OpencodeUsageAccumulator() : undefined; let relevantTraceBytes = 0; const finalMessageTrace: string[] = []; let finalMessageTraceBytes = 0; @@ -1793,6 +1795,7 @@ async function executeCommand( const codexIdentityPattern = /"type"\s*:\s*"thread\.started"/; const appendRelevantTrace = (line: string) => { let trimmed = line.trim(); + if (opencodeUsage?.addLine(trimmed)) return; if (!trimmed || !relevantTracePattern.test(trimmed)) return; let entryBytes = Buffer.byteLength(trimmed, "utf8") + 1; if (entryBytes > maxRelevantTraceBytes) { @@ -1858,7 +1861,7 @@ async function executeCommand( traceBuffer = ""; }; const readRelevantTrace = () => { - const rollingTrace = relevantTrace.join(""); + const rollingTrace = relevantTrace.join("") + (opencodeUsage?.trace() ?? ""); return pinnedIdentityTrace && !rollingTrace.includes(pinnedIdentityTrace) ? `${pinnedIdentityTrace}${rollingTrace}` : rollingTrace; diff --git a/src/opencode-usage.ts b/src/opencode-usage.ts new file mode 100644 index 0000000..db68485 --- /dev/null +++ b/src/opencode-usage.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function numbers(record: JsonRecord, fields: string[]): JsonRecord { + const result: JsonRecord = {}; + for (const field of fields) { + const value = record[field]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) result[field] = value; + } + return result; +} + +// Retain only compact usage for each completed step, independently of text trace eviction. +export class OpencodeUsageAccumulator { + private readonly steps = new Map(); + private anonymousSteps = 0; + + add(value: unknown): boolean { + const record = asRecord(value); + if (record.type !== "step_finish") return false; + const part = asRecord(record.part); + const sourceTokens = asRecord(part.tokens); + const tokens = numbers(sourceTokens, ["input", "output", "reasoning"]); + const cache = numbers(asRecord(sourceTokens.cache), ["read", "write"]); + if (Object.keys(tokens).length === 0 && Object.keys(cache).length === 0) return false; + if (Object.keys(cache).length > 0) tokens.cache = cache; + const id = asString(part.id); + const sessionID = asString(part.sessionID) || asString(record.sessionID); + const messageID = asString(part.messageID); + const key = id + ? createHash("sha256").update(JSON.stringify([sessionID, messageID, id])).digest("hex") + : this.anonymousSteps++; + this.steps.set(key, { + id: typeof key === "string" ? key : "", + sessionID: Buffer.byteLength(sessionID, "utf8") <= 16 * 1024 ? sessionID : "", + messageID: Buffer.byteLength(messageID, "utf8") <= 16 * 1024 ? messageID : "", + tokens, + ...numbers(part, ["cost"]), + }); + return true; + } + + addLine(line: string): boolean { + try { + return this.add(JSON.parse(line) as unknown); + } catch { + return false; + } + } + + parts(): JsonRecord[] { + return [...this.steps.values()]; + } + + trace(): string { + return this.parts().map((part) => JSON.stringify({ + type: "step_finish", sessionID: part.sessionID, part, + }) + "\n").join(""); + } +} diff --git a/src/usage.ts b/src/usage.ts index 757a5f6..6ce0cef 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -1,5 +1,6 @@ import type { AgentName } from "./types.js"; import type { ModelsDevPricingData } from "./models-dev.js"; +import { OpencodeUsageAccumulator } from "./opencode-usage.js"; export { fetchModelsDevPricing } from "./models-dev.js"; @@ -417,31 +418,44 @@ function extractGeminiUsage(records: JsonRecord[], context: UsageContext): Usage } function extractOpencodeUsage(records: JsonRecord[], context: UsageContext): UsageSummary | undefined { - const record = latestRecordWith(records, (item) => { - const tokens = asRecord(asRecord(item.part).tokens); - const cache = asRecord(tokens.cache); - return hasNumericField(tokens, ["input", "output", "reasoning"]) || hasNumericField(cache, ["read", "write"]); - }); - if (!record) return undefined; - const part = asRecord(record.part); - const tokens = asRecord(part.tokens); - const cache = asRecord(tokens.cache); + const steps = new OpencodeUsageAccumulator(); + for (const record of records) steps.add(record); + const parts = steps.parts(); + if (parts.length === 0) return undefined; + const tokens = parts.map((part) => asRecord(part.tokens)); + const sumTokens = (field: string) => tokens.reduce((sum, token) => sum + asNumber(token[field]), 0); + const sumCache = (field: string) => tokens.reduce((sum, token) => sum + asNumber(asRecord(token.cache)[field]), 0); + const costs = parts.map((part) => asOptionalNumber(part.cost)); + const totalCost = costs.every((cost) => cost !== undefined) + ? roundCost(costs.reduce((sum, cost) => sum + cost, 0)) + : undefined; const requested = requestedProviderModel(context); - const totalCost = asOptionalNumber(part.cost); + const model = extractModel(records, context); + const usage = { + inputTokens: sumTokens("input"), + cacheReadTokens: sumCache("read"), + cacheWriteTokens: sumCache("write"), + outputTokens: sumTokens("output"), + reasoningOutputTokens: sumTokens("reasoning"), + }; return summarizeUsage({ agent: "opencode", provider: requested.provider, - model: extractModel(records, context), - inputTokens: asNumber(tokens.input), - cacheReadTokens: asNumber(cache.read), - cacheWriteTokens: asNumber(cache.write), - outputTokens: asNumber(tokens.output), - reasoningOutputTokens: asNumber(tokens.reasoning), + model, + ...usage, reasoningOutputIncludedInOutput: false, cost: totalCost === undefined ? null : nativeCost(totalCost), costBasis: totalCost === undefined ? null : "native-reported", pricingSource: totalCost === undefined ? null : "native", pricingStatus: totalCost === undefined ? "missing" : "native", + modelBreakdowns: totalCost === undefined ? [{ + provider: requested.provider, + model, + inputTokens: usage.inputTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheWriteTokens: usage.cacheWriteTokens, + outputTokens: usage.outputTokens + usage.reasoningOutputTokens, + }] : undefined, }); } diff --git a/tests/headless.test.ts b/tests/headless.test.ts index 5271399..7c71db4 100644 --- a/tests/headless.test.ts +++ b/tests/headless.test.ts @@ -4543,7 +4543,7 @@ test("CLI --usage splits Pi provider/model specs", async () => { } }); -test("CLI --usage reports OpenCode hard default model", async () => { +test("CLI --usage reports OpenCode hard default model and sums completed steps", async () => { const dir = mkdtempSync(join(tmpdir(), "headless-test-")); try { const binDir = join(dir, "bin"); @@ -4556,6 +4556,7 @@ test("CLI --usage reports OpenCode hard default model", async () => { "#!/usr/bin/env node", "console.log(JSON.stringify({ role: 'assistant', parts: [{ type: 'text', text: 'final answer' }] }));", "console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 100, output: 10, reasoning: 5, cache: { read: 0, write: 0 } }, cost: 0.5 } }));", + "console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 200, output: 20, reasoning: 10, cache: { read: 30, write: 4 } }, cost: 1 } }));", "", ].join("\n"), ); @@ -4574,6 +4575,9 @@ test("CLI --usage reports OpenCode hard default model", async () => { assert.equal(usage.model, "gpt-5.4"); assert.equal(usage.pricingStatus, "native"); assert.equal(usage.costBasis, "native-reported"); + assert.equal(usage.cost.total, 1.5); + assert.equal(usage.inputTokens, 300); + assert.equal(usage.totalTokens, 379); } finally { rmSync(dir, { force: true, recursive: true }); } diff --git a/tests/opencode-streaming-usage.test.ts b/tests/opencode-streaming-usage.test.ts new file mode 100644 index 0000000..8663ddb --- /dev/null +++ b/tests/opencode-streaming-usage.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { runCli } from "../src/cli.ts"; + +test("CLI --json --usage retains distinct OpenCode steps across long streamed output", async () => { + const dir = mkdtempSync(join(tmpdir(), "headless-opencode-stream-")); + try { + const binary = join(dir, "opencode"); + writeFileSync( + binary, + [ + "#!/usr/bin/env node", + "function step(id, input, cost) {", + " return { type: 'step_finish', sessionID: 'session-1', part: {", + " id, sessionID: 'session-1', messageID: 'message-1', type: 'step-finish',", + " tokens: { input, output: 0 }, cost,", + " } };", + "}", + "const first = step('step-1', 100, 1);", + "console.log(JSON.stringify(first));", + "console.log(JSON.stringify(first));", + "for (let i = 0; i < 3000; i++) {", + " console.log(JSON.stringify({ type: 'text', part: { text: 'x'.repeat(150) } }));", + "}", + "const last = step('step-2', 200, 2);", + "console.log(JSON.stringify(last));", + "console.log(JSON.stringify(last));", + "", + ].join("\n"), + ); + chmodSync(binary, 0o755); + + const stdout: string[] = []; + const code = await runCli(["opencode", "--prompt", "hello", "--json", "--usage"], { + env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ""}` }, + stdout: (text) => stdout.push(text), + }); + + assert.equal(code, 0); + const output = stdout.join(""); + assert.ok(Buffer.byteLength(output) > 256 * 1024); + const { usage } = JSON.parse(output.trim().split("\n").at(-1)!); + assert.equal(usage.cost.total, 3); + assert.equal(usage.inputTokens, 300); + assert.equal(usage.totalTokens, 300); + assert.equal(usage.costBasis, "native-reported"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/tests/opencode-usage.test.ts b/tests/opencode-usage.test.ts new file mode 100644 index 0000000..db74230 --- /dev/null +++ b/tests/opencode-usage.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { extractUsageSummary, priceUsageSummary } from "../src/output.ts"; +import { OpencodeUsageAccumulator } from "../src/opencode-usage.ts"; + +function step(id: string | undefined, cost: unknown = 1) { + return { + type: "step_finish", + part: { + id, + sessionID: "session-1", + messageID: "message-1", + type: "step-finish", + cost, + tokens: { input: 100, output: 10, reasoning: 5, cache: { read: 20, write: 3 } }, + }, + }; +} + +function summarize(...records: unknown[]) { + return extractUsageSummary("opencode", records.map((record) => JSON.stringify(record)).join("\n"), { + model: "deepseek/deepseek-v4-pro", + }); +} + +test("OpenCode sums every completed step including cache and reasoning tokens", () => { + const second = step("step-2", 2); + second.part.tokens.input = 200; + const result = summarize(step("step-1"), second); + assert.equal(result.cost?.total, 3); + assert.equal(result.inputTokens, 300); + assert.equal(result.outputTokens, 20); + assert.equal(result.reasoningOutputTokens, 10); + assert.equal(result.cacheReadTokens, 40); + assert.equal(result.cacheWriteTokens, 6); + assert.equal(result.totalTokens, 376); + assert.equal(result.provider, "deepseek"); + assert.equal(result.model, "deepseek-v4-pro"); + assert.equal(result.costBasis, "native-reported"); +}); + +test("OpenCode replaces repeated step snapshots with the latest valid occurrence", () => { + const first = step("step-1"); + const updated = step("step-1", 2); + updated.part.tokens.input = 200; + const invalid = { ...updated, part: { ...updated.part, tokens: { input: "invalid" } } }; + const result = summarize(first, first, step("step-2", 3), updated, invalid); + assert.equal(result.cost?.total, 5); + assert.equal(result.inputTokens, 300); +}); + +test("OpenCode step identities include the message and session", () => { + const otherMessage = step("same-id", 2); + otherMessage.part.messageID = "message-2"; + const otherSession = step("same-id", 3); + otherSession.part.sessionID = "session-2"; + const result = summarize(step("same-id"), otherMessage, otherSession); + assert.equal(result.cost?.total, 6); + assert.equal(result.inputTokens, 300); +}); + +test("OpenCode preserves steps without identifiers", () => { + const result = summarize(step(undefined), step(undefined)); + assert.equal(result.cost?.total, 2); + assert.equal(result.inputTokens, 200); +}); + +test("OpenCode ignores unrelated token records and cumulative message costs", () => { + const result = summarize( + step("step-1"), + { type: "message.updated", info: { cost: 999, tokens: { input: 999 } } }, + { type: "tool_use", part: { cost: 999, tokens: { input: 999 } } }, + { type: "step_start", part: { cost: 999, tokens: { input: 999 } } }, + ); + assert.equal(result.cost?.total, 1); + assert.equal(result.inputTokens, 100); +}); + +test("OpenCode preserves explicit zero usage and zero cost", () => { + const result = summarize({ type: "step_finish", part: { cost: 0, tokens: { input: 0 } } }); + assert.equal(result.cost?.total, 0); + assert.equal(result.totalTokens, 0); + assert.equal(result.usageStatus, "reported"); +}); + +for (const cost of [undefined, null, -1, "2"]) { + test(`OpenCode does not report a partial native total when a step cost is ${String(cost)}`, () => { + const unpriced = step("step-1", cost); + unpriced.part.cost = cost; + const result = summarize(unpriced, step("step-2", 2)); + assert.equal(result.cost, null); + assert.equal(result.costBasis, null); + assert.equal(result.pricingStatus, "missing"); + assert.equal(result.inputTokens, 200); + const priced = priceUsageSummary(result, { + deepseek: { models: { "deepseek-v4-pro": { cost: { input: 1, output: 1, cache_read: 1, cache_write: 1 } } } }, + }); + assert.equal(priced.costBasis, "api-list-price-estimate"); + assert.equal(priced.inputTokens, 200); + assert.equal(priced.cost?.output, 0.00003); + assert.equal(priced.cost?.total, 0.000276); + }); +} + +test("OpenCode with no valid completed usage remains missing", () => { + const result = summarize({ type: "step_finish", part: { tokens: { input: -1, output: "4" } } }); + assert.equal(result.usageStatus, "missing"); + assert.equal(result.cost, null); +}); + +test("OpenCode retains compact usage and deduplication with oversized identity fields", () => { + const accumulator = new OpencodeUsageAccumulator(); + const record = step("x".repeat(1024 * 1024)); + record.part.sessionID = "s".repeat(1024 * 1024); + record.part.messageID = "m".repeat(1024 * 1024); + accumulator.add(record); + accumulator.add(record); + accumulator.add({ ...record, part: { ...record.part, id: "different" } }); + const trace = accumulator.trace(); + assert.ok(Buffer.byteLength(trace) < 1024); + const summary = extractUsageSummary("opencode", trace); + assert.equal(summary.cost?.total, 2); + assert.equal(summary.inputTokens, 200); +}); From b8883e9219d6a337dfeffdfb6e36993d17c11788 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Tue, 8 Sep 2026 20:55:17 +0000 Subject: [PATCH 2/2] test: make run-wait transcript timestamp deterministic --- tests/run-coordination.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/run-coordination.test.ts b/tests/run-coordination.test.ts index de4b8e9..501fafc 100644 --- a/tests/run-coordination.test.ts +++ b/tests/run-coordination.test.ts @@ -1093,6 +1093,8 @@ test("run wait reconciles completed tmux nodes from native transcripts", async ( "", ].join("\n"), ); + const transcriptTime = new Date(Date.now() + 1000); + utimesSync(transcriptPath, transcriptTime, transcriptTime); const stdout: string[] = []; assert.equal(