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 }),
};
}
}