Compare commits

...
6 changed files with 130 additions and 24 deletions
@@ -2,6 +2,7 @@ import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderM
import type { Model } from "@opencode-ai/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import path from "path"
import { SessionMessage } from "../message.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -16,7 +17,10 @@ const media = (file: FileAttachment): ContentPart => ({
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
if (file.source.type === "inline") {
if (!file.name) return undefined
return path.posix.isAbsolute(file.name) || path.win32.isAbsolute(file.name) ? file.name : undefined
}
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
try {
@@ -67,11 +71,11 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime) || file.mime === "application/pdf") {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
return []
const location = attachmentLocation(file)
return [
...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]),
...(imageMimes.has(file.mime) || file.mime === "application/pdf" ? [media(file)] : []),
]
}
const userAttachmentContent = (files: readonly FileAttachment[]) => {
+23 -4
View File
@@ -19,6 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
@@ -314,17 +315,18 @@ describe("Session.prompt", () => {
}),
)
it.effect("resolves attachment MIME before admission", () =>
it.effect("preserves image paths through MIME resolution, admission, and model context", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const uri =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
const name = path.resolve("/project/image.png")
const message = yield* session.prompt({
sessionID,
text: "Inspect this image",
files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }],
files: [{ uri, name, mention: { start: 8, end: 17, text: "[Image 1]" } }],
resume: false,
})
@@ -333,13 +335,30 @@ describe("Session.prompt", () => {
data: uri.slice(uri.indexOf(",") + 1),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
name,
mention: { start: 8, end: 17, text: "[Image 1]" },
},
])
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("user")
if (stored?.type === "user") expect(stored.payload.files).toEqual(message.payload.files)
if (stored?.type !== "user") throw new Error("Expected an admitted user prompt")
expect(stored.payload.files).toEqual(message.payload.files)
const messages = toLLMMessages(
[
SessionMessage.User.make({
...stored.payload,
id: message.id,
type: "user",
time: { created: DateTime.makeUnsafe(0) },
}),
],
Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
)
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: `Attached file: ${name}` },
{ type: "media", mediaType: "image/png", filename: name },
])
}),
)
@@ -431,7 +431,7 @@ Recent work
])
})
test("uses materialized image and PDF data as provider media", () => {
test.each([undefined, "", "clipboard", "image.png"])("does not invent a source path for %s", (name) => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
@@ -440,7 +440,7 @@ Recent work
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name }),
FileAttachment.make({
data: Base64.make("JVBERg=="),
mime: "application/pdf",
@@ -456,7 +456,7 @@ Recent work
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{ type: "media", mediaType: "image/png", data, ...(name === undefined ? {} : { filename: name }) },
{ type: "media", mediaType: "application/pdf", data: "JVBERg==", filename: "document.pdf" },
])
})
@@ -491,6 +491,65 @@ Recent work
])
})
test.each([
["image/png", "/home/user/image.png"],
["image/jpeg", "/home/user/image.jpeg"],
["image/gif", "/home/user/image.gif"],
["image/webp", "/home/user/image.webp"],
["application/pdf", "/home/user/document.pdf"],
["image/png", "C:\\Users\\user\\Downloads\\image.png"],
["application/pdf", "\\\\server\\share\\document.pdf"],
])("exposes inline %s source paths in model context (%s)", (mime, location) => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-inline-image-path"),
type: "user",
text: "Use this file",
files: [FileAttachment.make({ data, mime, source: { type: "inline" }, name: location })],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Use this file" },
{ type: "text", text: `Attached file: ${location}` },
{ type: "media", mediaType: mime, data, filename: location },
])
})
test.each(["image/avif", "application/octet-stream", "audio/mpeg"])(
"retains the source path when %s cannot be sent as media",
(mime) => {
const data = Base64.make("AAECAw==")
const location = path.resolve("/project/attachment")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-unsupported-media"),
type: "user",
text: "Use these files",
files: [
FileAttachment.make({ data, mime, source: { type: "inline" }, name: location }),
FileAttachment.make({ data, mime, source: { type: "uri", uri: pathToFileURL(location).href } }),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Use these files" },
{ type: "text", text: `Attached file: ${location}` },
{ type: "text", text: `Attached file: ${location}` },
])
},
)
test("falls back to attachment names for invalid local source paths", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
@@ -46,9 +46,9 @@ export async function resolvePastedAttachments(text: string, platform: string) {
}
return attachments.map((item) => {
const filename = path.basename(item.filepath)
const filename = path.resolve(item.filepath)
if (item.attachment.type === "text") {
return { type: "text" as const, content: item.attachment.content, filename }
return { type: "text" as const, content: `Attached file: ${filename}\n\n${item.attachment.content}`, filename }
}
return {
type: "file" as const,
+11 -2
View File
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import path from "node:path"
import { BoxRenderable, ImageRenderable, RGBA, type CliRenderer, type RootRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
@@ -458,8 +459,16 @@ test("mini attaches dropped image paths and removes attachments with their label
await sent.promise
expect(submitted[0].text).toBe("\u4e2d\u6587 [Image 1] [Image 2] ")
expect(submitted[0].parts).toMatchObject([
{ type: "file", filename: "one image.png", source: { text: { start: 5, end: 14, value: "[Image 1]" } } },
{ type: "file", filename: "two.png", source: { text: { start: 15, end: 24, value: "[Image 2]" } } },
{
type: "file",
filename: path.join(tmp.path, "one image.png"),
source: { text: { start: 5, end: 14, value: "[Image 1]" } },
},
{
type: "file",
filename: path.join(tmp.path, "two.png"),
source: { text: { start: 15, end: 24, value: "[Image 2]" } },
},
])
await app.waitFor(() => app.renderer.currentFocusedEditor?.plainText === "")
app.mockInput.pressKey("ARROW_UP")
@@ -97,11 +97,26 @@ describe("prompt local attachments", () => {
for (const input of [file, `'${file}'`, pathToFileURL(file).href]) {
expect(await resolvePastedAttachments(input, process.platform)).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
{ type: "file", uri: "data:image/png;base64,AQID", filename: file },
])
}
})
test.each(["avif", "gif", "jpeg", "jpg", "pdf", "png", "webp"])(
"retains an absolute path for dropped %s files",
async (extension) => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, `one image.${extension}`)
await Bun.write(file, new Uint8Array([1, 2, 3]))
for (const input of [file, path.relative(process.cwd(), file)]) {
expect(await resolvePastedAttachments(`"${input}"`, process.platform)).toMatchObject([
{ type: "file", filename: file },
])
}
},
)
test("resolves quoted paths and URI lists as ordered attachments", async () => {
await using tmp = await tmpdir()
const image = path.join(tmp.path, "one image.png")
@@ -113,8 +128,8 @@ describe("prompt local attachments", () => {
`# dropped files\r\n${pathToFileURL(image).href}\r\n${pathToFileURL(pdf).href}`,
]) {
expect(await resolvePastedAttachments(input, process.platform)).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
{ type: "file", uri: "data:application/pdf;base64,BAUG", filename: "two file.pdf" },
{ type: "file", uri: "data:image/png;base64,AQID", filename: image },
{ type: "file", uri: "data:application/pdf;base64,BAUG", filename: pdf },
])
}
})
@@ -137,14 +152,14 @@ describe("prompt local attachments", () => {
}
})
test("resolves SVG files as text with the original content", async () => {
test("includes the SVG path before its original text content", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "image.svg")
const content = "<svg />\r\n"
await Bun.write(file, content)
expect(await resolvePastedAttachments(file, process.platform)).toEqual([
{ type: "text", content, filename: "image.svg" },
{ type: "text", content: `Attached file: ${file}\n\n${content}`, filename: file },
])
})
@@ -159,8 +174,8 @@ describe("prompt local attachments", () => {
])
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toMatchObject([
{ type: "file", filename: "image.png" },
{ type: "text", content, filename: "image.svg" },
{ type: "file", filename: image },
{ type: "text", content: `Attached file: ${svg}\n\n${content}`, filename: svg },
])
await Bun.write(svg, content + " ")
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toBeUndefined()