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

This commit is contained in:
Shoubhit Dash 2026-05-25 20:53:58 +05:30 committed by GitHub
parent 7060cfa59b
commit 2fce3c1370
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 447 additions and 0 deletions

View file

@ -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
}
}

View file

@ -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",
},
},
},
])
})
})