diff --git a/packages/opencode/src/acp-next/content.ts b/packages/opencode/src/acp-next/content.ts new file mode 100644 index 000000000000..6d36fa60d8f1 --- /dev/null +++ b/packages/opencode/src/acp-next/content.ts @@ -0,0 +1,246 @@ +import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk" +import path from "node:path" +import { pathToFileURL } from "node:url" +import type { MessageV2 } from "@/session/message-v2" + +export type PromptPart = MessageV2.TextPartInput | MessageV2.FilePartInput + +export type ReplayPart = + | { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + } + | { + type: "file" + url: string + mime: string + filename?: string + } + | { + type: "reasoning" + text: string + } + +export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] { + return content.flatMap(contentBlockToParts) +} + +export function contentBlockToParts(block: ContentBlock): PromptPart[] { + switch (block.type) { + case "text": + return [ + { + type: "text", + text: block.text, + ...audienceFlags(block.annotations?.audience ?? undefined), + }, + ] + + case "image": + if (block.data) { + return [ + { + type: "file", + url: `data:${block.mimeType};base64,${block.data}`, + filename: filenameFromUri(block.uri ?? undefined) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("data:")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) { + return [ + { + type: "file", + url: block.uri, + filename: filenameFromUri(block.uri) ?? "image", + mime: block.mimeType, + }, + ] + } + return [] + + case "resource_link": + return [resourceLinkToPart(block)] + + case "resource": + if ("text" in block.resource) { + return [{ type: "text", text: block.resource.text }] + } + if (block.resource.mimeType) { + return [ + { + type: "file", + url: block.resource.uri.startsWith("data:") + ? block.resource.uri + : `data:${block.resource.mimeType};base64,${block.resource.blob}`, + filename: filenameFromUri(block.resource.uri) ?? "file", + mime: block.resource.mimeType, + }, + ] + } + return [] + + default: + return [] + } +} + +export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] { + return parts.flatMap(partToContentChunks) +} + +export function partToContentChunks(part: ReplayPart): ContentChunk[] { + switch (part.type) { + case "text": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + ...partAudience(part), + }, + }, + ] + + case "file": + return filePartToContentChunks(part) + + case "reasoning": + if (!part.text) return [] + return [ + { + content: { + type: "text", + text: part.text, + }, + }, + ] + } +} + +function resourceLinkToPart(link: ResourceLink): PromptPart { + const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name) + if (parsed.type === "file") return parsed + return { type: "text", text: parsed.text } +} + +function uriToFilePart(uri: string, mime: string, filename?: string): MessageV2.FilePartInput | MessageV2.TextPartInput { + try { + if (uri.startsWith("file://")) { + return { + type: "file", + url: uri, + filename: filename ?? filenameFromUri(uri) ?? "file", + mime, + } + } + if (uri.startsWith("zed://")) { + const pathname = new URL(uri).searchParams.get("path") + if (pathname) { + return { + type: "file", + url: pathToFileURL(pathname).href, + filename: filename ?? (path.basename(pathname) || "file"), + mime, + } + } + } + return { type: "text", text: uri } + } catch { + return { type: "text", text: uri } + } +} + +function filePartToContentChunks(part: Extract): ContentChunk[] { + if (part.url.startsWith("file://")) { + return [ + { + content: { + type: "resource_link", + uri: part.url, + name: part.filename ?? "file", + mimeType: part.mime, + }, + }, + ] + } + if (!part.url.startsWith("data:")) return [] + + const data = decodeDataUrl(part.url) + if (!data) return [] + if (data.mime.startsWith("image/")) { + return [ + { + content: { + type: "image", + mimeType: data.mime, + data: data.base64, + uri: pathToFileURL(part.filename ?? "image").href, + }, + }, + ] + } + + return [ + { + content: { + type: "resource", + resource: + data.mime.startsWith("text/") || data.mime === "application/json" + ? { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + text: Buffer.from(data.base64, "base64").toString("utf8"), + } + : { + uri: pathToFileURL(part.filename ?? "file").href, + mimeType: data.mime, + blob: data.base64, + }, + }, + }, + ] +} + +function decodeDataUrl(url: string) { + const match = /^data:([^;]+);base64,(.*)$/.exec(url) + if (!match) return + return { mime: match[1], base64: match[2] } +} + +function audienceFlags(audience: readonly Role[] | null | undefined) { + if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true } + if (audience?.length === 1 && audience[0] === "user") return { ignored: true } + return {} +} + +function partAudience(part: Extract) { + const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined + if (!audience) return {} + return { annotations: { audience } } +} + +function filenameFromUri(uri: string | undefined) { + if (!uri) return + if (uri.startsWith("data:")) return + try { + const parsed = new URL(uri) + const name = path.basename(parsed.pathname) + return name || undefined + } catch { + return path.basename(uri) || undefined + } +} diff --git a/packages/opencode/test/acp-next/content.test.ts b/packages/opencode/test/acp-next/content.test.ts new file mode 100644 index 000000000000..88f8608345b1 --- /dev/null +++ b/packages/opencode/test/acp-next/content.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from "bun:test" +import type { ContentBlock } from "@agentclientprotocol/sdk" +import { pathToFileURL } from "node:url" +import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content" + +describe("acp-next content conversion", () => { + test("plain text block becomes a text part", () => { + expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }]) + }) + + test("assistant-only text audience becomes synthetic", () => { + expect( + contentBlockToParts({ + type: "text", + text: "internal", + annotations: { audience: ["assistant"] }, + }), + ).toEqual([{ type: "text", text: "internal", synthetic: true }]) + }) + + test("user-only text audience becomes ignored", () => { + expect( + contentBlockToParts({ + type: "text", + text: "visible to user", + annotations: { audience: ["user"] }, + }), + ).toEqual([{ type: "text", text: "visible to user", ignored: true }]) + }) + + test("image block with base64 data becomes a data URL file part", () => { + expect( + contentBlockToParts({ + type: "image", + data: "AAAA", + mimeType: "image/png", + uri: "file:///tmp/screenshot.png", + }), + ).toEqual([ + { + type: "file", + url: "data:image/png;base64,AAAA", + filename: "screenshot.png", + mime: "image/png", + }, + ]) + }) + + test("image block with http URI becomes a file part", () => { + expect( + contentBlockToParts({ + type: "image", + data: "", + mimeType: "image/jpeg", + uri: "http://example.com/assets/photo.jpg", + }), + ).toEqual([ + { + type: "file", + url: "http://example.com/assets/photo.jpg", + filename: "photo.jpg", + mime: "image/jpeg", + }, + ]) + }) + + test("resource_link file URL becomes a file part with name and fallback mime", () => { + expect( + contentBlockToParts({ + type: "resource_link", + uri: "file:///tmp/notes.txt", + name: "client-notes.txt", + }), + ).toEqual([ + { + type: "file", + url: "file:///tmp/notes.txt", + filename: "client-notes.txt", + mime: "text/plain", + }, + ]) + }) + + test("resource_link zed path becomes a file URL part", () => { + expect( + contentBlockToParts({ + type: "resource_link", + uri: "zed://workspace?path=/tmp/project/src/app.ts", + name: "app.ts", + mimeType: "text/typescript", + }), + ).toEqual([ + { + type: "file", + url: pathToFileURL("/tmp/project/src/app.ts").href, + filename: "app.ts", + mime: "text/typescript", + }, + ]) + }) + + test("resource with text becomes a text part", () => { + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/context.txt", + mimeType: "text/plain", + text: "context", + }, + }), + ).toEqual([{ type: "text", text: "context" }]) + }) + + test("resource with blob and mimeType becomes a data URL file part", () => { + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: "file:///tmp/report.pdf", + mimeType: "application/pdf", + blob: "JVBERg==", + }, + }), + ).toEqual([ + { + type: "file", + url: "data:application/pdf;base64,JVBERg==", + filename: "report.pdf", + mime: "application/pdf", + }, + ]) + }) + + test("data URL resource is preserved as a file part", () => { + expect( + contentBlockToParts({ + type: "resource", + resource: { + uri: "data:text/plain;base64,aGVsbG8=", + mimeType: "text/plain", + blob: "ignored", + }, + }), + ).toEqual([ + { + type: "file", + url: "data:text/plain;base64,aGVsbG8=", + filename: "file", + mime: "text/plain", + }, + ]) + }) + + test("unsupported blocks are ignored", () => { + expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([]) + expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([]) + }) +}) + +describe("acp-next replay conversion", () => { + test("replays text audience annotations", () => { + expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([ + { + content: { + type: "text", + text: "cached", + annotations: { audience: ["assistant"] }, + }, + }, + ]) + }) + + test("replays file and data URL parts as ACP content", () => { + expect( + partsToContentChunks([ + { type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" }, + { type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" }, + ]), + ).toEqual([ + { + content: { + type: "resource_link", + uri: "file:///tmp/readme.md", + name: "readme.md", + mimeType: "text/markdown", + }, + }, + { + content: { + type: "resource", + resource: { + uri: pathToFileURL("note.txt").href, + mimeType: "text/plain", + text: "hello", + }, + }, + }, + ]) + }) +})