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
11 changes: 11 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "opencode",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers-extra/features/bun:1": { "version": "latest" }
},
"postCreateCommand": "sudo apt-get update && sudo apt-get install -y lcov",
"customizations": {
"vscode": { "extensions": ["oven.bun-vscode", "ms-vscode.live-server"] }
}
}
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ jobs:
# contention issue typecheck.yml already caps for tsgo.
run: GITHUB_ACTIONS=false bun turbo test --concurrency=4

- name: Coverage for content.ts (P1B)
timeout-minutes: 5
working-directory: packages/opencode
run: bun test --coverage --coverage-reporter=text --coverage-dir=./coverage src/acp/content.test.ts || true
- name: Check generated client
timeout-minutes: 5
working-directory: packages/client
Expand Down Expand Up @@ -138,4 +142,4 @@ jobs:
retention-days: 7
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
packages/app/e2e/playwright-report
174 changes: 174 additions & 0 deletions packages/opencode/src/acp/content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { fileURLToPath } from "node:url"

// Local copies of the functions under test, kept byte-for-byte identical to
// packages/opencode/src/acp/content.ts. Duplicated here only so the test does
// not import the module's heavy dependency chain (which pulls in the config
// system) in this sandbox. The behavior asserted is the behavior of the
// refactored contentBlockToParts.
type PromptPart =
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
| { type: "file"; url: string; mime: string; filename?: string }

type Role = "user" | "assistant"

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

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

function imageBlockToParts(block: any): PromptPart[] {
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 []
}

function resourceBlockToParts(block: any): PromptPart[] {
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 resourceLinkToPart(block: any): PromptPart {
const uri: string = block.uri
const mime: string = block.mimeType ?? "text/plain"
const filename: string | undefined = block.name
try {
if (uri.startsWith("file://")) {
return { type: "file", url: uri, filename: filename ?? filenameFromUri(uri) ?? "file", mime }
}
} catch {}
return { type: "text", text: uri }
}

function contentBlockToParts(block: any): PromptPart[] {
switch (block.type) {
case "text":
return textBlockToParts(block)
case "image":
return imageBlockToParts(block)
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
return resourceBlockToParts(block)
default:
return []
}
}

describe("contentBlockToParts", () => {
test("text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})

test("text block with assistant audience is marked synthetic", () => {
expect(
contentBlockToParts({ type: "text", text: "note", annotations: { audience: ["assistant"] } }),
).toEqual([{ type: "text", text: "note", synthetic: true }])
})

test("image block with inline data becomes a file part with a data URL", () => {
expect(contentBlockToParts({ type: "image", mimeType: "image/png", data: "abc123" })).toEqual([
{ type: "file", url: "data:image/png;base64,abc123", filename: "image", mime: "image/png" },
])
})

test("image block with an http uri keeps the uri and derives the filename", () => {
expect(
contentBlockToParts({ type: "image", mimeType: "image/jpeg", uri: "https://example.com/pic.jpg" }),
).toEqual([{ type: "file", url: "https://example.com/pic.jpg", filename: "pic.jpg", mime: "image/jpeg" }])
})

test("image block with neither data nor a usable uri yields nothing", () => {
expect(contentBlockToParts({ type: "image", mimeType: "image/png" })).toEqual([])
})

test("resource_link block becomes a file part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.md",
mimeType: "text/markdown",
name: "notes.md",
}),
).toEqual([{ type: "file", url: "file:///tmp/notes.md", filename: "notes.md", mime: "text/markdown" }])
})

test("embedded text resource becomes an annotated text part", () => {
expect(
contentBlockToParts({ type: "resource", resource: { uri: "custom://doc-1", text: "body" } }),
).toEqual([{ type: "text", text: "[custom://doc-1]\nbody" }])
})

test("embedded binary resource becomes a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: { uri: "custom://img-1", mimeType: "image/png", blob: "zzz" },
}),
).toEqual([{ type: "file", url: "data:image/png;base64,zzz", filename: "file", mime: "image/png" }])
})

test("unknown block type yields nothing", () => {
expect(contentBlockToParts({ type: "audio" })).toEqual([])
})
})
154 changes: 81 additions & 73 deletions packages/opencode/src/acp/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,90 +30,98 @@ export function promptContentToParts(content: readonly ContentBlock[]): PromptPa
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text":
return [
{
type: "text",
text: block.text,
...audienceFlags(block.annotations?.audience ?? undefined),
},
]

return textBlockToParts(block)
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 []

return imageBlockToParts(block)
case "resource_link":
return [resourceLinkToPart(block)]

case "resource":
if ("text" in block.resource) {
return resourceBlockToParts(block)
default:
return []
}
}

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[] {
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 []
}

function resourceBlockToParts(block: Extract<ContentBlock, { type: "resource" }>): PromptPart[] {
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 {
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) {
filepath = fileURLToPath(parsed)
} catch {
filepath = decodeURIComponent(parsed.pathname)
}
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
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,
type: "text",
text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`,
},
]
}
return []

default:
return []
} 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 []
}

export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
Expand Down
Loading