Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ jobs:
working-directory: packages/client
run: bun run check:generated

acp-content:
name: acp content
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"

- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Run ACP content tests
timeout-minutes: 5
working-directory: packages/opencode
run: bun test test/acp/content.test.ts

e2e:
name: e2e (smoke)
runs-on: ubuntu-latest
Expand Down
155 changes: 76 additions & 79 deletions packages/opencode/src/acp/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,92 +28,89 @@ export function promptContentToParts(content: readonly ContentBlock[]): PromptPa
}

export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text":
return handlers[block.type]?.(block as never) ?? []
}

function textBlockToParts(block: Extract<ContentBlock, { type: "text" }>): PromptPart[] {
return [
{
type: "text",
text: block.text,
...audienceFlags(block.annotations?.audience ?? undefined),
},
]
}

function imageBlockToParts(block: Extract<ContentBlock, { type: "image" }>): PromptPart[] {
const url = imageBlockUrl(block)
if (!url) return []
return [
{
type: "file",
url,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}

function imageBlockUrl(block: Extract<ContentBlock, { type: "image" }>) {
if (block.data) return `data:${block.mimeType};base64,${block.data}`
if (block.uri?.startsWith("data:")) return block.uri
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) return block.uri
}

function resourceLinkBlockToParts(block: Extract<ContentBlock, { type: "resource_link" }>): PromptPart[] {
return [resourceLinkToPart(block)]
}

function resourceBlockToParts(block: Extract<ContentBlock, { type: "resource" }>): PromptPart[] {
if ("text" in block.resource) return resourceTextToParts(block.resource)
if (!block.resource.mimeType) return []
return resourceBlobToParts(block.resource.uri, block.resource.mimeType, block.resource.blob)
}

function resourceTextToParts(resource: { uri: string; text: string }): PromptPart[] {
try {
const parsed = new URL(resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
let filepath: string
try {
filepath = fileURLToPath(parsed)
} catch {
filepath = decodeURIComponent(parsed.pathname)
}
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
return [
{
type: "text",
text: block.text,
...audienceFlags(block.annotations?.audience ?? undefined),
text: `[${filepath}${line ? `:${line}` : ""}]\n${resource.text}`,
},
]
}
} catch {}
return [{ type: "text", text: `[${resource.uri}]\n${resource.text}` }]
}

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) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
let filepath: string
try {
filepath = fileURLToPath(parsed)
} catch {
filepath = decodeURIComponent(parsed.pathname)
}
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
return [
{
type: "text",
text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`,
},
]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${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 []
function resourceBlobToParts(uri: string, mimeType: string, blob: string): PromptPart[] {
return [
{
type: "file",
url: uri.startsWith("data:") ? uri : `data:${mimeType};base64,${blob}`,
filename: filenameFromUri(uri) ?? "file",
mime: mimeType,
},
]
}

default:
return []
}
const handlers: {
[K in ContentBlock["type"]]?: (block: Extract<ContentBlock, { type: K }>) => PromptPart[]
} = {
text: textBlockToParts,
image: imageBlockToParts,
resource_link: resourceLinkBlockToParts,
resource: resourceBlockToParts,
}

export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/acp/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ describe("acp content conversion", () => {
])
})

test("image block with data URI is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/png",
uri: "data:image/png;base64,AAAA",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "image",
mime: "image/png",
},
])
})

test("image block without data or http URI is ignored", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/png",
uri: "file:///tmp/local.png",
}),
).toEqual([])
})

test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
Expand Down Expand Up @@ -146,6 +175,29 @@ describe("acp content conversion", () => {
}
})

test("resource with text falls back when file URL cannot be converted", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file://hostname/tmp/context.txt",
text: "context",
},
})
expect(result).toEqual([{ type: "text", text: "[/tmp/context.txt]\ncontext" }])
})

test("resource with blob and no mimeType is ignored", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.bin",
blob: "AA==",
},
}),
).toEqual([])
})

test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
Expand Down
Loading