diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1..5bbd38c7b49 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1150,6 +1150,144 @@ 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("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 8f7e42c11d7..b9d6cfd95ee 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,14 @@ 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; + // 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" @@ -930,7 +947,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 570629046a6..5a304a5e70f 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", + }); + }); });