diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index cccc2550179f..001793f114a1 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -697,6 +697,7 @@ export const RunCommand = effectCmd({ async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() const sessions = new Set([sessionID]) + const compactionMessages = new Set() let error: string | undefined for await (const event of events.stream) { @@ -704,6 +705,15 @@ export const RunCommand = effectCmd({ if (sessions.has(event.properties.info.parentID)) sessions.add(event.properties.info.id) } + if ( + event.type === "message.updated" && + event.properties.sessionID === sessionID && + event.properties.info.role === "assistant" && + event.properties.info.mode === "compaction" + ) { + compactionMessages.add(event.properties.info.id) + } + if ( event.type === "message.updated" && event.properties.sessionID === sessionID && @@ -720,6 +730,10 @@ export const RunCommand = effectCmd({ if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue + // Skip all parts from compaction messages — message.updated for + // compaction arrives before its parts (same fiber, sequential + // publish), so the Set is populated before we reach here. + if (compactionMessages.has(part.messageID)) continue if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue @@ -751,6 +765,7 @@ export const RunCommand = effectCmd({ } if (part.type === "text" && part.time?.end) { + if (part.synthetic) continue if (emit("text", { part })) continue const text = part.text.trim() if (!text) continue diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index d2d4bae87921..63dfe07b07ae 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -7,6 +7,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. @@ -141,6 +142,68 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + cliIt.concurrent( + "--format json omits compaction internals and preserves visible output", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply() + .reason("visible reasoning before compaction") + .text("visible before compaction") + .tool("bash", { command: "printf tool", description: "Print deterministic output" }) + .usage({ input: 95_000, output: 100 }), + reply().reason("internal compaction reasoning").text("internal compaction summary").stop(), + reply().text("visible after compaction").stop(), + ) + + const result = yield* opencode.run("use a tool and continue after compaction", { + format: "json", + extraArgs: ["--thinking", "--dangerously-skip-permissions"], + env: { + OPENCODE_DISABLE_AUTOCOMPACT: "0", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...testProviderConfig(llm.url), + compaction: { auto: true, prune: false, tail_turns: 0 }, + }), + }, + }) + + opencode.expectExit(result, 0) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.map((event) => event.type)).toEqual([ + "step_start", + "reasoning", + "text", + "tool_use", + "step_finish", + "step_start", + "text", + "step_finish", + ]) + expect(events.filter((event) => event.type === "text").map((event) => event.part)).toEqual([ + expect.objectContaining({ type: "text", text: "visible before compaction" }), + expect.objectContaining({ type: "text", text: "visible after compaction" }), + ]) + expect(events.find((event) => event.type === "reasoning")?.part).toEqual( + expect.objectContaining({ type: "reasoning", text: "visible reasoning before compaction" }), + ) + expect(events.find((event) => event.type === "tool_use")?.part).toEqual( + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ status: "completed" }), + }), + ) + expect(result.stdout).not.toContain("internal compaction") + expect(result.stdout).not.toContain("Continue if you have next steps") + // Prove the internal response became the summary used by the next turn, + // rather than merely relying on a queued mock response being consumed. + const requests = yield* llm.inputs + expect(requests.some((request) => JSON.stringify(request).includes("internal compaction summary"))).toBe(true) + }), + 60_000, + ) + cliIt.concurrent( "--format json emits a pure error record for a rejected prompt request", ({ opencode }) =>