diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ed9f558aeaf..967f56bedc3 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -1,5 +1,6 @@ import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai" import { Option, Schema } from "effect" +import { fileURLToPath } from "url" import type { Model } from "../../model.js" import { SessionMessage } from "../message.js" import type { FileAttachment } from "@opencode-ai/schema/prompt" @@ -14,6 +15,17 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) +const attachmentLocation = (file: FileAttachment) => { + if (file.source.type !== "uri") return undefined + const url = URL.parse(file.source.uri) + if (url?.protocol !== "file:") return undefined + try { + return fileURLToPath(url) + } catch { + return undefined + } +} + const textAttachment = (file: FileAttachment): ContentPart => ({ type: "text", text: `\n\n${[ @@ -36,7 +48,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({ const directoryAttachment = (file: FileAttachment): ContentPart => ({ type: "text", text: `\n\n${[ - `Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, + `Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`, file.description === undefined ? undefined : `Description: ${file.description}`, file.data.length === 0 ? undefined : "", file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"), @@ -55,7 +67,10 @@ 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)) return [media(file)] + if (imageMimes.has(file.mime)) { + const location = attachmentLocation(file) + return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)] + } return [] } diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index ce49a72714a..f624c402de8 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -11,6 +11,8 @@ import { Shell } from "@opencode-ai/schema/shell" import { Location } from "@opencode-ai/schema/location" import { AbsolutePath } from "@opencode-ai/schema/schema" import { DateTime } from "effect" +import path from "path" +import { pathToFileURL } from "url" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) @@ -267,12 +269,13 @@ Recent work ]) }) - test("lowers directory attachments as directory context", () => { + test("exposes admitted reference directory source paths in model context", () => { + const location = path.resolve("/references/harness-engineering") const directory = FileAttachment.make({ data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")), mime: "application/x-directory", - source: { type: "uri", uri: "file:///project/src" }, - name: "src/", + source: { type: "uri", uri: pathToFileURL(location).href }, + name: "harness-engineering", }) const messages = toLLMMessages( [ @@ -295,14 +298,15 @@ Recent work { type: "text", text: "Review this directory" }, { type: "text", - text: "\n\nAttached directory: src/\n\nlib/\nindex.ts", - metadata: { attachment: { source: directory.source, name: "src/" } }, + text: `\n\nAttached directory: ${location}\n\nlib/\nindex.ts`, + metadata: { attachment: { source: directory.source, name: "harness-engineering" } }, }, ], }) }) test("preserves attachment order after the prompt", () => { + const directory = path.resolve("/project/src") const messages = toLLMMessages( [ SessionMessage.User.make({ @@ -313,7 +317,7 @@ Recent work FileAttachment.make({ data: Base64.make(Buffer.from("index.ts").toString("base64")), mime: "application/x-directory", - source: { type: "uri", uri: "file:///project/src" }, + source: { type: "uri", uri: pathToFileURL(directory).href }, name: "src/", }), FileAttachment.make({ @@ -332,12 +336,13 @@ Recent work expect(messages).toHaveLength(1) expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([ "Review these attachments", - "\n\nAttached directory: src/\n\nindex.ts", + `\n\nAttached directory: ${directory}\n\nindex.ts`, "\n\nAttached file: main.ts\n\nexport const value = 1", ]) }) test("omits empty prompt text before an attachment", () => { + const directory = path.resolve("/project/src") const messages = toLLMMessages( [ SessionMessage.User.make({ @@ -348,7 +353,7 @@ Recent work FileAttachment.make({ data: Base64.make(Buffer.from("index.ts").toString("base64")), mime: "application/x-directory", - source: { type: "uri", uri: "file:///project/src" }, + source: { type: "uri", uri: pathToFileURL(directory).href }, name: "src/", }), ], @@ -359,7 +364,9 @@ Recent work ) expect(messages).toHaveLength(1) - expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }]) + expect(messages[0]?.content).toMatchObject([ + { type: "text", text: `\n\nAttached directory: ${directory}\n\nindex.ts` }, + ]) }) test("uses materialized image data as provider media and drops unsupported attachments", () => { @@ -391,6 +398,108 @@ Recent work ]) }) + test("exposes admitted local image source paths before provider media", () => { + const data = Base64.make("AAECAw==") + const location = path.resolve("/project/IMG_3480.JPG") + const image = FileAttachment.make({ + data, + mime: "image/png", + source: { type: "uri", uri: pathToFileURL(location).href }, + name: "IMG_3480.JPG", + }) + + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-local-image-path"), + type: "user", + text: "Inspect this image", + files: [image], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { type: "text", text: `Attached file: ${location}` }, + { type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" }, + ]) + }) + + test("falls back to attachment names for invalid local source paths", () => { + const data = Base64.make("AAECAw==") + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-invalid-local-paths"), + type: "user", + text: "Inspect these attachments", + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("index.ts").toString("base64")), + mime: "application/x-directory", + source: { type: "uri", uri: "file:///project/src%2Flib" }, + name: "src/", + }), + FileAttachment.make({ + data, + mime: "image/png", + source: { type: "uri", uri: "file:///project/image%2Fpreview.png" }, + name: "preview.png", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect these attachments" }, + { + type: "text", + text: "\n\nAttached directory: src/\n\nindex.ts", + metadata: { + attachment: { + source: { type: "uri", uri: "file:///project/src%2Flib" }, + name: "src/", + }, + }, + }, + { type: "media", mediaType: "image/png", data, filename: "preview.png" }, + ]) + }) + + test("does not add attachment location text for non-local provider media", () => { + const data = Base64.make("AAECAw==") + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-remote-image"), + type: "user", + text: "Inspect this image", + files: [ + FileAttachment.make({ + data, + mime: "image/png", + source: { type: "uri", uri: "https://example.com/image.png" }, + name: "image.png", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { type: "media", mediaType: "image/png", data, filename: "image.png" }, + ]) + }) + test("deduplicates provider media while preserving durable attachment references", () => { const data = Base64.make("AAECAw==") const messages = toLLMMessages( @@ -468,7 +577,7 @@ Recent work FileAttachment.make({ data, mime: "image/png", - source: { type: "uri", uri: "file:///project/image.png" }, + source: { type: "uri", uri: pathToFileURL(path.resolve("/project/image.png")).href }, name: "image.png", mention: { start: 0, end: 9, text: "[Image 1]" }, }),