From 178708a44e0d9ba1049a033aed2979af424521a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 25 May 2026 18:04:33 +0800 Subject: [PATCH] Show OpenCode tool activity consistently (#1171) * fix(server): map OpenCode tool details * test(server): update OpenCode history client stub --- .../agent/providers/opencode-agent.test.ts | 165 ++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 64 +++++++ .../opencode/event-translator.test.ts | 137 +++++++++++++++ .../opencode/tool-call-detail-parser.ts | 143 ++++++++++++++- .../opencode/tool-call-mapper.test.ts | 83 +++++++++ 5 files changed, 591 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index e12fdb088..82deedbc9 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1082,6 +1082,171 @@ describe("OpenCode adapter startTurn error handling", () => { ]); }); + test("streamHistory maps persisted OpenCode tool parts through canonical detail branches", async () => { + const patchText = [ + "*** Begin Patch", + "*** Delete File: /tmp/repo/src/App.tsx", + "*** End Patch", + ].join("\n"); + + const fakeClient = { + session: { + get: vi.fn().mockResolvedValue({ + data: { revert: undefined }, + error: undefined, + }), + messages: vi.fn().mockResolvedValue({ + data: [ + { + info: { + id: "msg_assistant", + sessionID: "ses_unit_test", + role: "assistant", + }, + parts: [ + { + id: "part-grep", + sessionID: "ses_unit_test", + messageID: "msg_assistant", + type: "tool", + tool: "grep", + callID: "call-grep", + state: { + status: "completed", + input: { pattern: "sendCorrelatedSessionRequest" }, + }, + }, + { + id: "part-skill", + sessionID: "ses_unit_test", + messageID: "msg_assistant", + type: "tool", + tool: "skill", + callID: "call-skill", + state: { + status: "completed", + input: { name: "diagnose" }, + output: '# Skill: diagnose', + }, + }, + { + id: "part-apply-patch", + sessionID: "ses_unit_test", + messageID: "msg_assistant", + type: "tool", + tool: "apply_patch", + callID: "call-apply-patch", + state: { + status: "completed", + input: { patchText }, + output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx", + }, + }, + { + id: "part-todowrite", + sessionID: "ses_unit_test", + messageID: "msg_assistant", + type: "tool", + tool: "todowrite", + callID: "call-todowrite", + state: { + status: "completed", + input: { + todos: [ + { + content: "Inspect current directory and existing files", + status: "completed", + priority: "high", + }, + ], + }, + }, + }, + ], + }, + ], + error: undefined, + }), + }, + } as never; + + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/repo" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + ); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-grep", + name: "grep", + status: "completed", + detail: { + type: "search", + query: "sendCorrelatedSessionRequest", + toolName: "grep", + }, + error: null, + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-skill", + name: "skill", + status: "completed", + detail: { + type: "plain_text", + label: "diagnose", + icon: "sparkles", + text: '# Skill: diagnose', + }, + error: null, + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-apply-patch", + name: "apply_patch", + status: "completed", + detail: { + type: "edit", + filePath: "/tmp/repo/src/App.tsx", + unifiedDiff: [ + "diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx", + "--- a//tmp/repo/src/App.tsx", + "+++ /dev/null", + ].join("\n"), + }, + error: null, + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "todo", + items: [{ text: "Inspect current directory and existing files", completed: true }], + }, + }, + ]); + }); + test("emits turn_failed when client.session.promptAsync throws synchronously", async () => { // Yield the server-connected event, then park forever. The adapter waits // for that first event before sending the prompt. diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index e8e9dc3a7..c5be8b74e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -884,6 +884,17 @@ function buildOpenCodeReplayPartTimelineEvent(params: { if (part.type !== "tool") { return null; } + if (isOpenCodeTodoWriteToolPart(part)) { + const todos = readOpenCodeTodoItemsFromToolPart(part); + if (!todos) { + return null; + } + return buildOpenCodeReplayTimelineEvent({ + item: mapOpenCodeTodosToTimelineItems(todos), + message, + part, + }); + } const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); if (!parsedToolPart.success || !parsedToolPart.data) { return null; @@ -1537,6 +1548,56 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function isOpenCodeTodoWriteToolPart(part: OpenCodeToolPartEventPart | OpenCodePart): boolean { + return part.type === "tool" && part.tool.trim().toLowerCase() === "todowrite"; +} + +function readOpenCodeTodoItems( + value: unknown, +): Array<{ content?: string | null; status?: string | null }> | null { + if (typeof value === "string") { + try { + return readOpenCodeTodoItems(JSON.parse(value)); + } catch { + return null; + } + } + if (Array.isArray(value)) { + return value.flatMap((entry) => { + const record = readOpenCodeRecord(entry); + if (!record) { + return []; + } + const content = readNonEmptyString(record.content); + if (!content) { + return []; + } + return [ + { + content, + status: readNonEmptyString(record.status), + }, + ]; + }); + } + const record = readOpenCodeRecord(value); + if (!record) { + return null; + } + return readOpenCodeTodoItems(record.todos); +} + +function readOpenCodeTodoItemsFromToolPart( + part: Extract, +): Array<{ content?: string | null; status?: string | null }> | null { + const state = readOpenCodeRecord(part.state); + return ( + readOpenCodeTodoItems(state?.input) ?? + readOpenCodeTodoItems(state?.output) ?? + readOpenCodeTodoItems(state?.metadata) + ); +} + function mapOpenCodeTodosToTimelineItems( todos: Array<{ content?: string | null; status?: string | null }>, ): Extract { @@ -2093,6 +2154,9 @@ function appendOpenCodeMessagePartUpdated( events: AgentStreamEvent[], ): void { const part = event.properties.part; + if (part.type === "tool" && isOpenCodeTodoWriteToolPart(part)) { + return; + } if (part.sessionID !== state.sessionId) { if (part.type === "tool") { appendOpenCodeSubAgentChildToolPart(part, state, events); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index ff5e5e4c0..1a9fe8b07 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -541,6 +541,143 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("suppresses live todowrite tool parts because OpenCode emits todo.updated separately", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "part-todowrite", + sessionID: "session-1", + messageID: "message-1", + type: "tool", + tool: "todowrite", + callID: "call-todowrite", + state: { + status: "running", + input: {}, + }, + }, + }, + }, + state, + ); + + expect(events).toEqual([]); + }); + + it("maps live OpenCode tool parts through canonical detail branches", () => { + const state = createState(); + + const patchText = [ + "*** Begin Patch", + "*** Delete File: /tmp/repo/src/App.tsx", + "*** End Patch", + ].join("\n"); + + const events = [ + { + id: "part-grep", + tool: "grep", + callID: "call-grep", + state: { status: "completed", input: { pattern: "sendCorrelatedSessionRequest" } }, + }, + { + id: "part-skill", + tool: "skill", + callID: "call-skill", + state: { + status: "completed", + input: { name: "diagnose" }, + output: '# Skill: diagnose', + }, + }, + { + id: "part-apply-patch", + tool: "apply_patch", + callID: "call-apply-patch", + state: { + status: "completed", + input: { patchText }, + output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx", + }, + }, + ].flatMap((part) => + translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + ...part, + sessionID: "session-1", + messageID: "message-1", + type: "tool", + }, + }, + }, + state, + ), + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-grep", + name: "grep", + status: "completed", + detail: { + type: "search", + query: "sendCorrelatedSessionRequest", + toolName: "grep", + }, + error: null, + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-skill", + name: "skill", + status: "completed", + detail: { + type: "plain_text", + label: "diagnose", + icon: "sparkles", + text: '# Skill: diagnose', + }, + error: null, + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + callId: "call-apply-patch", + name: "apply_patch", + status: "completed", + detail: { + type: "edit", + filePath: "/tmp/repo/src/App.tsx", + unifiedDiff: [ + "diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx", + "--- a//tmp/repo/src/App.tsx", + "+++ /dev/null", + ].join("\n"), + }, + error: null, + }, + }, + ]); + }); + it("emits compaction loading timeline items from compaction parts", () => { const state = createState(); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 9c5a5bbcc..670286344 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import type { ToolCallDetail } from "../../agent-sdk-types.js"; -import { nonEmptyString } from "../tool-call-mapper-utils.js"; +import { nonEmptyString, truncateDiffText } from "../tool-call-mapper-utils.js"; import { ToolEditInputSchema, ToolEditOutputSchema, + ToolGrepOutputSchema, ToolGlobOutputSchema, ToolReadInputSchema, ToolReadOutputSchema, @@ -49,6 +50,109 @@ function readOutputText(value: unknown): string | undefined { return undefined; } +function parseApplyPatchDirective( + line: string, +): { kind: "add" | "update" | "delete"; path: string } | null { + const trimmed = line.trim(); + if (trimmed.startsWith("*** Add File:")) { + return { kind: "add", path: trimmed.replace("*** Add File:", "").trim() }; + } + if (trimmed.startsWith("*** Update File:")) { + return { kind: "update", path: trimmed.replace("*** Update File:", "").trim() }; + } + if (trimmed.startsWith("*** Delete File:")) { + return { kind: "delete", path: trimmed.replace("*** Delete File:", "").trim() }; + } + return null; +} + +function extractApplyPatchPrimaryFilePath(patchText: string): string | undefined { + for (const line of patchText.split(/\r?\n/)) { + const directive = parseApplyPatchDirective(line); + if (directive?.path) { + return directive.path; + } + } + return undefined; +} + +function normalizeDiffHeaderPath(rawPath: string): string { + return rawPath.trim().replace(/^["']+|["']+$/g, ""); +} + +function applyPatchToUnifiedDiff(patchText: string): string { + const lines = patchText.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let sawDiffContent = false; + + for (const line of lines) { + const directive = parseApplyPatchDirective(line); + if (directive) { + const filePath = normalizeDiffHeaderPath(directive.path); + if (filePath.length > 0) { + if (output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + const left = directive.kind === "add" ? "/dev/null" : `a/${filePath}`; + const right = directive.kind === "delete" ? "/dev/null" : `b/${filePath}`; + output.push(`diff --git a/${filePath} b/${filePath}`); + output.push(`--- ${left}`); + output.push(`+++ ${right}`); + sawDiffContent = true; + } + continue; + } + + const trimmed = line.trim(); + if ( + trimmed === "*** Begin Patch" || + trimmed === "*** End Patch" || + trimmed === "*** End of File" || + trimmed.startsWith("*** Move to:") + ) { + continue; + } + + if ( + line.startsWith("@@") || + line.startsWith("+") || + line.startsWith("-") || + line.startsWith(" ") || + line.startsWith("\\ No newline at end of file") + ) { + output.push(line); + sawDiffContent = true; + } + } + + if (!sawDiffContent) { + return patchText; + } + + const normalized = output.join("\n").trim(); + return normalized.length > 0 ? normalized : patchText; +} + +const OpencodeApplyPatchTextInputSchema = z + .object({ patchText: z.string() }) + .passthrough() + .transform((value) => { + const filePath = extractApplyPatchPrimaryFilePath(value.patchText); + return { + filePath: filePath ?? "", + oldString: undefined, + newString: undefined, + unifiedDiff: truncateDiffText(applyPatchToUnifiedDiff(value.patchText)), + }; + }); + +const OpencodeGrepOutputSchema = z + .union([ + ToolGrepOutputSchema, + z.string().transform((output) => ({ numFiles: 0, filenames: [], content: output })), + ]) + .nullable(); + function formatLogEntry(value: unknown): string | undefined { const outputText = readOutputText(value); if (outputText) { @@ -167,6 +271,12 @@ const OpencodeKnownToolDetailSchema = z.union([ ToolEditOutputSchema, toEditToolDetail, ), + toolDetailBranchByToolName( + "apply_patch", + OpencodeApplyPatchTextInputSchema, + z.unknown(), + (input) => toEditToolDetail(input, null), + ), toolDetailBranchByToolName( "apply_diff", ToolEditInputSchema, @@ -176,6 +286,12 @@ const OpencodeKnownToolDetailSchema = z.union([ toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) => toSearchToolDetail({ input, toolName: "search" }), ), + toolDetailBranchByToolName( + "grep", + ToolSearchInputSchema, + OpencodeGrepOutputSchema, + (input, output) => toSearchToolDetail({ input, output, toolName: "grep" }), + ), toolDetailBranchByToolName("glob", ToolSearchInputSchema, z.unknown(), (input, output) => { const parsedOutput = ToolGlobOutputSchema.safeParse(output); return toSearchToolDetail({ @@ -187,6 +303,31 @@ const OpencodeKnownToolDetailSchema = z.union([ toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) => toSearchToolDetail({ input, toolName: "web_search" }), ), + toolDetailBranchByToolName( + "skill", + z.object({ name: z.string() }).passthrough(), + z + .union([ + z + .object({ output: z.string() }) + .passthrough() + .transform((value) => value.output), + z.string(), + ]) + .nullable(), + (input, output) => { + const skillName = input?.name.trim(); + if (!skillName) { + return undefined; + } + return { + type: "plain_text" as const, + label: skillName, + icon: "sparkles" as const, + ...(output ? { text: output } : {}), + } satisfies ToolCallDetail; + }, + ), ]); export function deriveOpencodeToolDetail( diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index a0db0272d..a40d7adfa 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -103,6 +103,21 @@ describe("opencode tool-call mapper", () => { query: "**/*.md", toolName: "glob", }); + + const grepItem = expectMapped( + mapOpencodeToolCall({ + toolName: "grep", + callId: "opencode-running-grep", + status: "running", + input: { pattern: "sendCorrelatedSessionRequest" }, + output: null, + }), + ); + expect(grepItem.detail).toEqual({ + type: "search", + query: "sendCorrelatedSessionRequest", + toolName: "grep", + }); }); it("maps completed read calls", () => { @@ -230,6 +245,74 @@ describe("opencode tool-call mapper", () => { }); }); + it("maps skill calls to plain text detail with the loaded skill name", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "skill", + callId: "opencode-skill-1", + status: "completed", + input: { name: "diagnose" }, + output: '# Skill: diagnose', + }), + ); + + expect(item.detail).toEqual({ + type: "plain_text", + label: "diagnose", + icon: "sparkles", + text: '# Skill: diagnose', + }); + }); + + it("maps completed grep calls with string output into search detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "grep", + callId: "opencode-grep-string-output-1", + status: "completed", + input: { pattern: "todowrite" }, + output: "Found 2 matches\nsrc/file.ts:\n Line 1: todowrite", + }), + ); + + expect(item.detail).toEqual({ + type: "search", + query: "todowrite", + toolName: "grep", + content: "Found 2 matches\nsrc/file.ts:\n Line 1: todowrite", + numFiles: 0, + }); + }); + + it("maps apply_patch patchText payloads into edit detail", () => { + const patchText = [ + "*** Begin Patch", + "*** Delete File: /tmp/repo/src/App.tsx", + "*** End Patch", + ].join("\n"); + + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "apply_patch", + callId: "opencode-apply-patch-text-1", + status: "completed", + input: { patchText }, + output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx", + }), + ); + + expect(item.detail.type).toBe("edit"); + expect(item.detail).toEqual({ + type: "edit", + filePath: "/tmp/repo/src/App.tsx", + unifiedDiff: [ + "diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx", + "--- a//tmp/repo/src/App.tsx", + "+++ /dev/null", + ].join("\n"), + }); + }); + it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( mapOpencodeToolCall({