Explorar o código

feat(acp-next): add content conversion helpers (#29231)

Shoubhit Dash hai 3 meses
pai
achega
2fce3c1370

+ 246 - 0
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<ReplayPart, { type: "file" }>): 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<ReplayPart, { type: "text" }>) {
+  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
+  }
+}

+ 201 - 0
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",
+          },
+        },
+      },
+    ])
+  })
+})