From f08593b1c5a10d37cdb1078091451873abd69f80 Mon Sep 17 00:00:00 2001 From: Brynne Taylor <7542439+brynne8@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:50:39 +0800 Subject: [PATCH 1/2] fix(opencode): show bash commands instead of repeating their output OpenCode bash tool calls rendered the command output as the collapsed row title and again inside the expanded body while the actual command was never visible (#7307). The adapter dropped `state.input.command` and stuffed the output into `detail`, so clients fell back to reading `detail` as the command. Project the invocation into the shape clients already render for Claude/Codex: `detail`/`data.command` carry the command and the output moves to `data.rawOutput.content`. The unread raw `state` blob is no longer duplicated over the wire. Worked on by ox-alpha via opencode. --- .../provider/Layers/OpenCodeAdapter.test.ts | 81 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 23 +++++- .../src/session-logic.command-output.test.ts | 20 +++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..3561cdcdbedc 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1150,6 +1150,87 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("projects bash tool parts as command plus raw output instead of output-as-detail", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-bash-projection"); + const part = { + id: "part-bash", + callID: "call-bash", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-bash", + type: "tool" as const, + tool: "bash", + state: { + status: "running" as const, + input: { command: "git status" }, + title: "git status", + time: { start: 1 }, + }, + }; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { sessionID: part.sessionID, part, time: 1 }, + }, + { + type: "message.part.updated", + properties: { + sessionID: part.sessionID, + part: { + ...part, + state: { + status: "completed" as const, + input: { command: "git status" }, + title: "git status", + output: "On branch main\nnothing to commit\n", + time: { start: 1, end: 2 }, + }, + }, + time: 2, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "item.updated" || event.type === "item.completed"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events.length, 2); + const updated = events[0]; + const completed = events[1]; + if (updated?.type !== "item.updated" || completed?.type !== "item.completed") { + NodeAssert.fail(`unexpected events: ${events.map((event) => event.type).join(", ")}`); + } + // The command itself is the detail/preview; the output rides in + // data.rawOutput so clients render it once in the expanded body. + NodeAssert.equal(updated.payload.detail, "git status"); + NodeAssert.deepEqual( + (updated.payload.data as Record).command, + "git status", + ); + NodeAssert.equal(completed.payload.detail, "git status"); + NodeAssert.deepEqual(completed.payload.data, { + tool: "bash", + command: "git status", + rawOutput: { content: "On branch main\nnothing to commit\n" }, + }); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..71fa556592b8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -492,6 +492,16 @@ function messageRoleForPart( return part.type === "tool" ? "assistant" : undefined; } +// OpenCode carries a bash invocation at `state.input.command`; project it +// into the command/rawOutput shape every client already renders (mirrors +// Claude/Codex adapters). Without this, clients fall back to reading the +// output in `detail` as the command (#7307). +function commandFromToolInput(part: Extract): string | undefined { + const input = (part.state as { input?: Record }).input; + const command = typeof input?.command === "string" ? input.command.trim() : ""; + return command || undefined; +} + function detailFromToolPart(part: Extract): string | undefined { switch (part.state.status) { case "completed": @@ -918,7 +928,9 @@ export function makeOpenCodeAdapter( const itemType = toToolLifecycleItemType(part.tool); const title = part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; - const detail = detailFromToolPart(part); + const command = + itemType === "command_execution" ? commandFromToolInput(part) : undefined; + const detail = command ?? detailFromToolPart(part); const payload = { itemType, ...(part.state.status === "error" @@ -930,7 +942,14 @@ export function makeOpenCodeAdapter( ...(detail ? { detail } : {}), data: { tool: part.tool, - state: part.state, + ...(command + ? { + command, + ...(part.state.status === "completed" + ? { rawOutput: { content: part.state.output } } + : {}), + } + : {}), }, }; const runtimeEvent: ProviderRuntimeEvent = { diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts index 570629046a60..5a304a5e70f6 100644 --- a/apps/web/src/session-logic.command-output.test.ts +++ b/apps/web/src/session-logic.command-output.test.ts @@ -82,4 +82,24 @@ describe("deriveWorkLogEntries command output", () => { expect(entry?.command).toBe("true"); expect(entry?.detail).toBeUndefined(); }); + + it("renders the OpenCode bash command as the preview and its output once", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("opencode-command", { + itemType: "command_execution", + title: "bash", + detail: "git status", + data: { + tool: "bash", + command: "git status", + rawOutput: { content: "On branch main\nnothing to commit" }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "git status", + detail: "On branch main\nnothing to commit", + }); + }); }); From e343705e311b1de55ac9db72dc6df5d22d9e63bd Mon Sep 17 00:00:00 2001 From: Brynne Taylor <7542439+brynne8@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:03:16 +0800 Subject: [PATCH 2/2] fix(opencode): keep failure reason on failed bash tool rows The command-as-detail preference also applied to errored parts, and with data.state gone the error text was dropped entirely. Errored command parts now keep state.error as detail while the invocation stays in data.command. Worked on by ox-alpha via opencode. --- .../provider/Layers/OpenCodeAdapter.test.ts | 57 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 7 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3561cdcdbedc..5bbd38c7b49f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1231,6 +1231,63 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("keeps the failure reason on failed bash parts while preserving the command", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-bash-failure"); + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-bash-fail", + callID: "call-bash-fail", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-bash-fail", + type: "tool" as const, + tool: "bash", + state: { + status: "error" as const, + input: { command: "exit 3" }, + title: "exit 3", + error: "Command failed with exit code 3", + time: { start: 1, end: 2 }, + }, + }, + time: 2, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "item.completed", + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const completed = events[0]; + if (completed?.type !== "item.completed") { + NodeAssert.fail(`unexpected events: ${events.map((event) => event.type).join(", ")}`); + } + NodeAssert.equal(completed.payload.status, "failed"); + NodeAssert.equal(completed.payload.detail, "Command failed with exit code 3"); + NodeAssert.deepEqual( + (completed.payload.data as Record).command, + "exit 3", + ); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 71fa556592b8..b9d6cfd95ee7 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -930,7 +930,12 @@ export function makeOpenCodeAdapter( part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; const command = itemType === "command_execution" ? commandFromToolInput(part) : undefined; - const detail = command ?? detailFromToolPart(part); + // Failed commands surface the failure reason; the invocation + // itself stays in data.command for the row preview. + const detail = + part.state.status === "error" + ? (detailFromToolPart(part) ?? command) + : (command ?? detailFromToolPart(part)); const payload = { itemType, ...(part.state.status === "error"