diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..9148f41b5da8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -696,28 +696,40 @@ 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. 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 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") { 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 @@ -738,15 +750,15 @@ export const RunCommand = effectCmd({ } if (part.type === "step-start") { - if (emit("step_start", { part })) continue + if (emit("step_start", { part, ...model })) continue } if (part.type === "step-finish") { - if (emit("step_finish", { part })) 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) { @@ -759,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 bd5847e2723c..54d47d617eb6 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,26 @@ 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" }), + providerID: provider, + modelID: model, + }, + { + type: "step_finish", + part: expect.objectContaining({ type: "step-finish" }), + providerID: provider, + modelID: model, }, - { type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) }, ]) expect(result.stdout.endsWith("\n")).toBe(true) expect( @@ -140,6 +153,62 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // 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 every part-bearing event to the model that produced it", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + 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", + 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", "reasoning", "text", "tool_use", "step_finish"]), + ) + for (const event of events) { + expect(event.providerID).toBe("test") + expect(event.modelID).toBe("test-model-alt") + } + }), + 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 cfb5a93e33e5..f30c8c20cc96 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) @@ -10,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", @@ -29,6 +33,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 }, },