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
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,13 +697,23 @@ export const RunCommand = effectCmd({
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
const toggles = new Map<string, boolean>()
const sessions = new Set([sessionID])
const compactionMessages = new Set<string>()
let error: string | undefined

for await (const event of events.stream) {
if (event.type === "session.created" && event.properties.info.parentID) {
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 &&
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }) =>
Expand Down
Loading