From 41aaba0342d581f5be4c221260655ba24cc19ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Tue, 4 Aug 2026 21:48:48 -0300 Subject: [PATCH 1/6] fix(opencode): add model attribution to run --format json step events The step_start/step_finish events emitted by `run --format json` carried no model information, so a headless consumer could not attribute token usage or cost to a model. The data was already available in the same loop: the CLI reads `info.modelID` off `message.updated` to print the model header in the default format, but never passed it to the JSON path. Track providerID/modelID per messageID and include them in the step events. --- packages/opencode/src/cli/cmd/run.ts | 25 ++++++++++++------- .../opencode/test/cli/run/run-process.test.ts | 17 ++++++++++--- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..cfdbc2eb05d0 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -696,20 +696,27 @@ export const RunCommand = effectCmd({ // created, and replies issued from inside the loop must use that client. async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + // messageID -> model that produced it, so step events can carry the + // attribution that only lives on the assistant message. + const models = new Map() let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && event.properties.sessionID === sessionID && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true + event.properties.info.role === "assistant" ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + models.set(event.properties.info.id, { + providerID: event.properties.info.providerID, + modelID: event.properties.info.modelID, + }) + if (args.format !== "json" && toggles.get("start") !== true) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } } if (event.type === "message.part.updated") { @@ -738,11 +745,11 @@ export const RunCommand = effectCmd({ } if (part.type === "step-start") { - if (emit("step_start", { part })) continue + if (emit("step_start", { part, ...models.get(part.messageID) })) continue } if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue + if (emit("step_finish", { part, ...models.get(part.messageID) })) continue } if (part.type === "text" && part.time?.end) { diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..6d5635e278b2 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -6,7 +6,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { reply } from "../../lib/llm-server" -import { cliIt } from "../../lib/cli-process" +import { cliIt, testModelID } from "../../lib/cli-process" describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. @@ -121,13 +121,24 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.sessionID).toBe("string") } expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + const [provider, model] = testModelID.split("/") expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ - { type: "step_start", part: expect.objectContaining({ type: "step-start" }) }, + { + type: "step_start", + part: expect.objectContaining({ type: "step-start" }), + providerID: provider, + modelID: model, + }, { type: "text", part: expect.objectContaining({ type: "text", text: "structured output" }), }, - { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, + { + type: "step_finish", + part: expect.objectContaining({ type: "step-finish" }), + providerID: provider, + modelID: model, + }, ]) expect(result.stdout.endsWith("\n")).toBe(true) expect( From 7a0daff9b658c31e79a89fb22b0262162f097bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Tue, 4 Aug 2026 22:19:51 -0300 Subject: [PATCH 2/6] test(opencode): cover model attribution with a non-default model --- .../opencode/test/cli/run/run-process.test.ts | 23 +++++++++++++++++++ packages/opencode/test/lib/test-provider.ts | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 6d5635e278b2..c95fa9bf3a7e 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -151,6 +151,29 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // The model only exists on the assistant message, so a step event has to pick + // it up from there. Running a non-default model proves the attribution follows + // the model that produced the step instead of a fixed value. + cliIt.concurrent( + "--format json attributes step events to the model that produced them", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("from the alt model") + const result = yield* opencode.run("say hi", { format: "json", model: "test/test-model-alt" }) + opencode.expectExit(result, 0) + + const steps = opencode + .parseJsonEvents(result.stdout) + .filter((event) => event.type === "step_start" || event.type === "step_finish") + expect(steps.length).toBeGreaterThan(0) + for (const step of steps) { + expect(step.providerID).toBe("test") + expect(step.modelID).toBe("test-model-alt") + } + }), + 60_000, + ) + cliIt.concurrent( "--format json emits a pure error record for a rejected prompt request", ({ opencode }) => diff --git a/packages/opencode/test/lib/test-provider.ts b/packages/opencode/test/lib/test-provider.ts index cfb5a93e33e5..3e359b173488 100644 --- a/packages/opencode/test/lib/test-provider.ts +++ b/packages/opencode/test/lib/test-provider.ts @@ -29,6 +29,20 @@ export function testProviderConfig(llmUrl: string) { cost: { input: 0, output: 0 }, options: {}, }, + // Second model so tests can assert output is attributed to the model + // that produced it, not to a hardcoded default. + "test-model-alt": { + id: "test-model-alt", + name: "Test Model Alt", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100_000, output: 10_000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, }, options: { apiKey: "test-key", baseURL: llmUrl }, }, From 4444b1bd11e773e3a68cc1b8857f93ea108832c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Tue, 4 Aug 2026 23:13:34 -0300 Subject: [PATCH 3/6] docs(opencode): document unknown-model case and fix stale test provider comment --- packages/opencode/src/cli/cmd/run.ts | 5 ++++- packages/opencode/test/lib/test-provider.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index cfdbc2eb05d0..79e9ff178e96 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -697,7 +697,10 @@ export const RunCommand = effectCmd({ async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() // messageID -> model that produced it, so step events can carry the - // attribution that only lives on the assistant message. + // attribution that only lives on the assistant message. Only filled from + // messages seen on this stream, so a step for a message created before + // this subscription (attaching to a turn already in flight) emits + // without the fields rather than guessing. const models = new Map() let error: string | undefined diff --git a/packages/opencode/test/lib/test-provider.ts b/packages/opencode/test/lib/test-provider.ts index 3e359b173488..8602ca3ecec2 100644 --- a/packages/opencode/test/lib/test-provider.ts +++ b/packages/opencode/test/lib/test-provider.ts @@ -1,7 +1,8 @@ // Shared provider config for tests that need opencode to talk to a fake LLM -// over a real HTTP endpoint. Registers a single provider `test` with a single -// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the -// caller supplies (typically a TestLLMServer instance). +// over a real HTTP endpoint. Registers a single provider `test` with two models, +// `test-model` (the default `--model test/test-model`) and `test-model-alt`, for +// tests that need output attributed to a specific model. Both point at the URL +// the caller supplies (typically a TestLLMServer instance). // // Used by: // - test/lib/run-process.ts (subprocess CLI tests) From f48dc9eb23dab1e0897c49461f51df64a2484494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Wed, 5 Aug 2026 06:02:08 -0300 Subject: [PATCH 4/6] fix(opencode): stamp the model on every part-bearing json event step_start/step_finish carried the model but text, reasoning and tool_use did not, so a consumer reading only those had to correlate back to a step event. Same lookup, hoisted once per part. --- packages/opencode/src/cli/cmd/run.ts | 12 ++++--- .../opencode/test/cli/run/run-process.test.ts | 33 ++++++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 79e9ff178e96..9148f41b5da8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -726,8 +726,10 @@ export const RunCommand = effectCmd({ const part = event.properties.part if (part.sessionID !== sessionID) continue + const model = models.get(part.messageID) + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue + if (emit("tool_use", { part, ...model })) continue if (part.state.status === "completed") { await tool(part) continue @@ -748,15 +750,15 @@ export const RunCommand = effectCmd({ } if (part.type === "step-start") { - if (emit("step_start", { part, ...models.get(part.messageID) })) continue + if (emit("step_start", { part, ...model })) continue } if (part.type === "step-finish") { - if (emit("step_finish", { part, ...models.get(part.messageID) })) continue + if (emit("step_finish", { part, ...model })) continue } if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue + if (emit("text", { part, ...model })) continue const text = part.text.trim() if (!text) continue if (!process.stdout.isTTY) { @@ -769,7 +771,7 @@ export const RunCommand = effectCmd({ } if (part.type === "reasoning" && part.time?.end && thinking) { - if (emit("reasoning", { part })) continue + if (emit("reasoning", { part, ...model })) continue const text = part.text.trim() if (!text) continue const line = `Thinking: ${text}` diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index c95fa9bf3a7e..2226f8d2f465 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -132,6 +132,8 @@ describe("opencode run (non-interactive subprocess)", () => { { type: "text", part: expect.objectContaining({ type: "text", text: "structured output" }), + providerID: provider, + modelID: model, }, { type: "step_finish", @@ -151,24 +153,31 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) - // The model only exists on the assistant message, so a step event has to pick - // it up from there. Running a non-default model proves the attribution follows - // the model that produced the step instead of a fixed value. + // The model only exists on the assistant message, so the events have to pick it + // up from there. Running a non-default model proves the attribution follows the + // model that produced the part instead of a fixed value, and a tool call makes + // the run span several part types. cliIt.concurrent( - "--format json attributes step events to the model that produced them", + "--format json attributes every part-bearing event to the model that produced it", ({ llm, opencode }) => Effect.gen(function* () { - yield* llm.text("from the alt model") + yield* llm.push( + reply().text("before tool").tool("bash", { + command: "printf tool-output", + description: "Print deterministic output", + }), + ) + yield* llm.text("after tool") const result = yield* opencode.run("say hi", { format: "json", model: "test/test-model-alt" }) opencode.expectExit(result, 0) - const steps = opencode - .parseJsonEvents(result.stdout) - .filter((event) => event.type === "step_start" || event.type === "step_finish") - expect(steps.length).toBeGreaterThan(0) - for (const step of steps) { - expect(step.providerID).toBe("test") - expect(step.modelID).toBe("test-model-alt") + const events = opencode.parseJsonEvents(result.stdout) + expect(new Set(events.map((event) => event.type))).toEqual( + new Set(["step_start", "text", "tool_use", "step_finish"]), + ) + for (const event of events) { + expect(event.providerID).toBe("test") + expect(event.modelID).toBe("test-model-alt") } }), 60_000, From 3d3efb9edddd750506f598d9d2b21412162f87cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Wed, 5 Aug 2026 09:43:28 -0300 Subject: [PATCH 5/6] test(opencode): cover reasoning in the attribution test reasoning was the one stamped event type with no attribution assertion: reverting its emit left the suite green. The attribution run now covers all five part-bearing types. --- packages/opencode/test/cli/run/run-process.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 2226f8d2f465..e4c15a53c94f 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -162,18 +162,22 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text("before tool").tool("bash", { + reply().reason("thinking it over").text("before tool").tool("bash", { command: "printf tool-output", description: "Print deterministic output", }), ) yield* llm.text("after tool") - const result = yield* opencode.run("say hi", { format: "json", model: "test/test-model-alt" }) + const result = yield* opencode.run("say hi", { + format: "json", + model: "test/test-model-alt", + extraArgs: ["--thinking"], + }) opencode.expectExit(result, 0) const events = opencode.parseJsonEvents(result.stdout) expect(new Set(events.map((event) => event.type))).toEqual( - new Set(["step_start", "text", "tool_use", "step_finish"]), + new Set(["step_start", "reasoning", "text", "tool_use", "step_finish"]), ) for (const event of events) { expect(event.providerID).toBe("test") From ee60502b07a78b7f9b13a29b4be3331ccc1fab3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duany=20Bar=C3=B3=20Men=C3=A9ndez?= Date: Wed, 5 Aug 2026 16:18:09 -0300 Subject: [PATCH 6/6] test(opencode): prove the model comes from the message, not the argv Every existing test passes --model, so an implementation reading the model off the argv passed the whole suite. This one runs without --model and only passes if the value really comes from the assistant message. --- .../opencode/test/cli/run/run-process.test.ts | 22 +++++++++++++++++++ packages/opencode/test/lib/test-provider.ts | 3 +++ 2 files changed, 25 insertions(+) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index e4c15a53c94f..54d47d617eb6 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -187,6 +187,28 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // Every other test passes --model, so they can't tell the model being read off + // the message from one read off the argv. Without --model the server resolves + // the configured default and the argv holds nothing, so this run only reports + // a model if it really comes from the assistant message. + cliIt.concurrent( + "--format json attributes events to the resolved default model without --model", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("default model run") + const result = yield* opencode.spawn(["run", "--format", "json", "say hi"]) + opencode.expectExit(result, 0) + + const events = opencode.parseJsonEvents(result.stdout) + expect(events.length).toBeGreaterThan(0) + for (const event of events) { + expect(event.providerID).toBe("test") + expect(event.modelID).toBe("test-model") + } + }), + 60_000, + ) + cliIt.concurrent( "--format json emits a pure error record for a rejected prompt request", ({ opencode }) => diff --git a/packages/opencode/test/lib/test-provider.ts b/packages/opencode/test/lib/test-provider.ts index 8602ca3ecec2..f30c8c20cc96 100644 --- a/packages/opencode/test/lib/test-provider.ts +++ b/packages/opencode/test/lib/test-provider.ts @@ -11,6 +11,9 @@ export function testProviderConfig(llmUrl: string) { return { formatter: false, lsp: false, + // Default model, so a run without --model still resolves one and tests can + // tell a value read off the message from one read off the argv. + model: "test/test-model", provider: { test: { name: "Test",