diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..7740b78a404d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -696,6 +696,10 @@ 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() + // Track each part's type so message.part.delta (which carries field + // but not part type) can be limited to text parts. Reasoning parts + // update the same "text" field but must not surface as text_delta. + const partTypes = new Map() let error: string | undefined for await (const event of events.stream) { @@ -715,6 +719,7 @@ export const RunCommand = effectCmd({ if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue + partTypes.set(part.id, part.type) if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue @@ -773,6 +778,29 @@ export const RunCommand = effectCmd({ } } + // message.part.delta carries each streaming text chunk as it is + // produced. Under --format json, mirror it as a text_delta event so + // consumers can render incremental progress instead of waiting for the + // final complete text event (still emitted above for backward + // compatibility). Non-json mode ignores deltas; its text rendering is + // driven by the complete part in message.part.updated. + if (event.type === "message.part.delta") { + const props = event.properties + if ( + props.sessionID === sessionID && + props.field === "text" && + typeof props.delta === "string" && + partTypes.get(props.partID) === "text" + ) { + emit("text_delta", { + messageID: props.messageID, + partID: props.partID, + delta: props.delta, + }) + } + continue + } + if (event.type === "session.error") { const props = event.properties if (props.sessionID !== sessionID || !props.error) continue diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..ae417f960f67 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -120,9 +120,20 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "text_delta", + "text", + "step_finish", + ]) expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ { type: "step_start", part: expect.objectContaining({ type: "step-start" }) }, + { + type: "text_delta", + messageID: expect.any(String), + partID: expect.any(String), + delta: "structured output", + }, { type: "text", part: expect.objectContaining({ type: "text", text: "structured output" }), @@ -140,6 +151,40 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // Regression for #38638: message.part.delta was dropped, so --format json + // only ever emitted the complete text after generation finished. Now each + // streaming chunk surfaces as a text_delta ahead of the final text event. + cliIt.concurrent( + "--format json emits text_delta events that stream text as it arrives (regression for #38638)", + ({ llm, opencode }) => + Effect.gen(function* () { + // Two text chunks on one reply become two message.part.delta events; + // their deltas must concatenate to the full text, ahead of the final + // complete text event which is retained for backward compatibility. + yield* llm.push(reply().text("Hello ").text("world").stop().item()) + const result = yield* opencode.run("stream me", { format: "json" }) + opencode.expectExit(result, 0) + + const events = opencode.parseJsonEvents(result.stdout) + const types = events.map((event) => event.type) + const deltas = events.filter((event) => event.type === "text_delta") + + expect(deltas.length).toBe(2) + expect(deltas.map((event) => event.delta).join("")).toBe("Hello world") + for (const event of deltas) { + expect(typeof event.messageID).toBe("string") + expect(typeof event.partID).toBe("string") + } + // text_delta events precede the final complete text event. + expect(types.lastIndexOf("text_delta")).toBeLessThan(types.indexOf("text")) + // The complete text event is retained for backward compatibility. + expect(events.find((event) => event.type === "text")?.part).toEqual( + expect.objectContaining({ type: "text", text: "Hello world" }), + ) + }), + 60_000, + ) + cliIt.concurrent( "--format json emits a pure error record for a rejected prompt request", ({ opencode }) => @@ -185,10 +230,12 @@ describe("opencode run (non-interactive subprocess)", () => { expect(events.map((event) => event.type)).toEqual([ "step_start", "reasoning", + "text_delta", "text", "tool_use", "step_finish", "step_start", + "text_delta", "text", "step_finish", ]) @@ -229,6 +276,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(result.exitCode).toBe(0) expect(events.map((event) => event.type)).toEqual([ "step_start", + "text_delta", "text", "tool_use", "step_finish",