diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 72348b98aa77..01ee5c2138ce 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -902,8 +902,16 @@ describe("OpenCodeAdapterV2", () => { ), ); - it.effect("titles OpenCode reads and searches from their input", () => + it.effect("projects OpenCode read output and search titles from native tool calls", () => Effect.gen(function* () { + const directoryOutput = + "/workspace/src\ndirectory\n\nenv.ts\nlib/\n\n(2 entries)\n"; + const fileOutput = + "/workspace/src/env.ts\nfile\n\n1: hello\n2: literal\n3: world\n"; + const fileWithInstructions = + "/workspace/src/env.ts\nfile\n1: hello\n\n(End of file - total 1 lines)\n\n\n\nProject instructions\n"; + const missingPath = directoryOutput.slice(directoryOutput.indexOf("")); + const missingClose = fileOutput.slice(0, fileOutput.lastIndexOf("\n")); const nativeEvents = asyncEventStream(); const nativeSessionId = "native-opencode-search"; const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { @@ -913,6 +921,7 @@ describe("OpenCodeAdapterV2", () => { return { stream: nativeEvents.stream }; }, }, + mcp: { status: async () => ({ data: {} }) }, session: { create: async () => ({ data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, @@ -928,24 +937,31 @@ describe("OpenCodeAdapterV2", () => { Stream.runCollect, Effect.forkScoped, ); - for (const [tool, input, output] of [ + const toolCalls = [ ["read", { filePath: "src/env.ts" }, "---\nfile body"], + ["read", { filePath: "/workspace/src" }, directoryOutput], + ["read", { filePath: "/workspace/src/env.ts" }, fileOutput], + ["read", { filePath: "/workspace/src/env.ts" }, fileWithInstructions], + ["read", { filePath: "/workspace/src" }, missingPath], + ["read", { filePath: "/workspace/src/env.ts" }, missingClose], + ["custom_read", { filePath: "/workspace/src" }, directoryOutput], ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], ["glob", { pattern: "missing", path: "apps/web" }, ""], ["codesearch", {}, " \n\t"], - ] as const) { + ] as const; + for (const [index, [tool, input, output]] of toolCalls.entries()) { yield* Effect.promise(() => nativeEvents.push({ type: "message.part.updated", properties: { sessionID: nativeSessionId, part: { - id: `part-${tool}`, + id: `part-${index}`, sessionID: nativeSessionId, messageID: "assistant-search", type: "tool", - callID: `call-${tool}`, + callID: `call-${index}`, tool, state: { status: "completed", @@ -969,8 +985,24 @@ describe("OpenCodeAdapterV2", () => { const items = (yield* Fiber.join(received)).flatMap((event) => event.type === "turn_item.updated" ? [event.turnItem] : [], ); - const read = items.find((item) => item.type === "dynamic_tool"); - assert.equal(read?.title, "Read src/env.ts"); + const reads = items.filter((item) => item.type === "dynamic_tool"); + assert.equal(reads[0]?.title, "Read src/env.ts"); + assert.deepEqual( + reads.map((item) => ({ toolName: item.toolName, output: item.output })), + [ + { toolName: "read", output: "---\nfile body" }, + { toolName: "read", output: "env.ts\nlib/\n\n(2 entries)" }, + { toolName: "read", output: "1: hello\n2: literal\n3: world" }, + { + toolName: "read", + output: + "1: hello\n\n(End of file - total 1 lines)\n\n\nProject instructions\n", + }, + { toolName: "read", output: missingPath }, + { toolName: "read", output: missingClose }, + { toolName: "custom_read", output: directoryOutput }, + ], + ); const grep = items.find((item) => item.type === "file_search"); assert.equal(grep?.title, "Searched TODO in web"); assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); @@ -1002,6 +1034,8 @@ describe("OpenCodeAdapterV2", () => { it.effect("presents OpenCode MCP calls without treating remote tools as local edits", () => Effect.gen(function* () { + const output = + "/workspace/src\ndirectory\n\nenv.ts\nlib/\n\n(2 entries)\n"; const nativeEvents = asyncEventStream(); const nativeSessionId = "native-opencode-mcp"; let statusReads = 0; @@ -1057,7 +1091,7 @@ describe("OpenCodeAdapterV2", () => { input: { document: "remote-doc" }, title: "Edit remote document", metadata: {}, - output: "saved", + output, error: "failed", time: { start: 1, end: 2 }, }, @@ -1150,6 +1184,7 @@ describe("OpenCodeAdapterV2", () => { }); assert.deepEqual(item.input, { document: "remote-doc" }); } + assert.equal(items[1]?.output, output); assert.equal(items[3]?.toolName, "ambiguous_server_edit_document"); assert.isUndefined(items[3]?.toolSource); assert.equal(statusReads, 1); diff --git a/packages/provider-opencode/src/server/toolItems.ts b/packages/provider-opencode/src/server/toolItems.ts index f4551cdd279c..c222c223bf89 100644 --- a/packages/provider-opencode/src/server/toolItems.ts +++ b/packages/provider-opencode/src/server/toolItems.ts @@ -66,6 +66,19 @@ function stableJson(value: unknown): string { } } +function nativeReadOutput(output: string): string { + const header = /^[^\r\n]+<\/path>\r?\n(directory|file)<\/type>\r?\n/.exec(output); + if (header === null) return output; + const body = output.slice(header[0].length); + // File lines are numbered; only a closing tag on its own line ends the envelope. + const envelope = + header[1] === "directory" + ? /^\r?\n([\s\S]*?)\r?\n<\/entries>(?=\r?\n|$)/.exec(body) + : /^(?:\r?\n)?([\s\S]*?)\r?\n<\/content>(?=\r?\n|$)/.exec(body); + // OpenCode can append instructions after the envelope; keep that suffix verbatim. + return envelope === null ? output : envelope[1] + body.slice(envelope[0].length); +} + export function openCodeToolTurnItem( base: ToolItemBase, tool: { @@ -142,6 +155,8 @@ export function openCodeToolTurnItem( } case "dynamic_tool": { const readPath = recordString(input, "filePath", "path", "file"); + const cleanedOutput = + tool.name === "read" && output !== undefined ? nativeReadOutput(output) : output; return { ...base, title: @@ -151,7 +166,7 @@ export function openCodeToolTurnItem( type: "dynamic_tool", toolName: tool.name, input, - ...(output === undefined ? {} : { output }), + ...(cleanedOutput === undefined ? {} : { output: cleanedOutput }), }; } }