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({