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]*?)${tagName}>`, "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),