mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-27 08:58:10 +00:00
feat(acp-next): add content conversion helpers (#29231)
This commit is contained in:
parent
7060cfa59b
commit
2fce3c1370
2 changed files with 447 additions and 0 deletions
246
packages/opencode/src/acp-next/content.ts
Normal file
246
packages/opencode/src/acp-next/content.ts
Normal 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
|
||||
}
|
||||
}
|
||||
201
packages/opencode/test/acp-next/content.test.ts
Normal file
201
packages/opencode/test/acp-next/content.test.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue