Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
// 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<string, { providerID: string; modelID: string }>()
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
Expand All @@ -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) {
Expand All @@ -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}`
Expand Down
75 changes: 72 additions & 3 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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 }) =>
Expand Down
24 changes: 21 additions & 3 deletions packages/opencode/test/lib/test-provider.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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",
Expand All @@ -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 },
},
Expand Down
Loading