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 280e2b6e4..9a28fdc01 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 @@ -182,6 +182,36 @@ describe("opencode tool-call mapper", () => { } }); + it("unwraps OpenCode XML read output into file content", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "read", + callId: "opencode-read-xml", + status: "completed", + input: { filePath: "/Users/moboudra/dev/paseo/docs/release.md" }, + output: [ + "/Users/moboudra/dev/paseo/docs/release.md", + "file", + "", + "1: # Release", + "2:", + "3: All workspaces share one version and release together.", + "", + ].join("\n"), + }), + ); + + expect(item.detail).toEqual({ + type: "read", + filePath: "/Users/moboudra/dev/paseo/docs/release.md", + content: [ + "1: # Release", + "2:", + "3: All workspaces share one version and release together.", + ].join("\n"), + }); + }); + it("maps failed calls with required error", () => { const item = expectMapped( mapOpencodeToolCall({ diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index b88fe497b..54ea8adf8 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -260,8 +260,69 @@ function flattenReadContent( return flattenToolReadContent(value); } +function readXmlTag(value: string, tagName: "path" | "content"): string | undefined { + const match = value.match(new RegExp(`<${tagName}>([\\s\\S]*?)`, "i")); + return match?.[1]; +} + +function decodeXmlText(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-fA-F]+)|amp|lt|gt|quot|apos);/g, + (entity, decimal, hex) => { + if (decimal) { + return String.fromCodePoint(Number.parseInt(decimal, 10)); + } + if (hex) { + return String.fromCodePoint(Number.parseInt(hex, 16)); + } + switch (entity) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case """: + return '"'; + case "'": + return "'"; + default: + return entity; + } + }, + ); +} + +function trimOuterLineBreaks(value: string): string { + return value.replace(/^\r?\n/, "").replace(/\r?\n$/, ""); +} + +function parseXmlReadOutput(value: string): ToolReadOutputValue | undefined { + const trimmed = value.trim(); + if (!trimmed.startsWith("") || !//i.test(trimmed)) { + return undefined; + } + + const filePath = nonEmptyString(readXmlTag(trimmed, "path")?.trim()); + const content = readXmlTag(trimmed, "content"); + if (!filePath && content === undefined) { + return undefined; + } + + return { + filePath: filePath ? decodeXmlText(filePath) : undefined, + content: content === undefined ? undefined : decodeXmlText(trimOuterLineBreaks(content)), + }; +} + const ToolReadOutputContentSchema = z.union([ - z.string().transform((value) => ({ filePath: undefined, content: nonEmptyString(value) })), + z.string().transform((value) => { + const xmlReadOutput = parseXmlReadOutput(value); + if (xmlReadOutput) { + return xmlReadOutput; + } + return { filePath: undefined, content: nonEmptyString(value) }; + }), ToolReadChunkSchema.transform((value) => ({ filePath: undefined, content: flattenReadContent(value),