diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e05ce79..3474e30e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/packages/opencode/src/acp/content.ts b/packages/opencode/src/acp/content.ts index 9207dd7c..e87e1f48 100644 --- a/packages/opencode/src/acp/content.ts +++ b/packages/opencode/src/acp/content.ts @@ -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): PromptPart[] { + return [ + { + type: "text", + text: block.text, + ...audienceFlags(block.annotations?.audience ?? undefined), + }, + ] +} + +function imageBlockToParts(block: Extract): 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) { + 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): PromptPart[] { + return [resourceLinkToPart(block)] +} + +function resourceBlockToParts(block: Extract): 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) => PromptPart[] +} = { + text: textBlockToParts, + image: imageBlockToParts, + resource_link: resourceLinkBlockToParts, + resource: resourceBlockToParts, } export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] { diff --git a/packages/opencode/test/acp/content.test.ts b/packages/opencode/test/acp/content.test.ts index ab5ffdb9..610dc367 100644 --- a/packages/opencode/test/acp/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -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({ @@ -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({