Skip to content
Closed
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
28 changes: 28 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
// 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<string, string>()
let error: string | undefined

for await (const event of events.stream) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand All @@ -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 }) =>
Expand Down Expand Up @@ -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",
])
Expand Down Expand Up @@ -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",
Expand Down
Loading