From 28de367444fa7bcf0558de5c315fa0058763c983 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 00:18:22 -0400 Subject: [PATCH] fix(core): materialize file and directory attachments before provider lowering --- .../core/src/session/runner/attachment.ts | 101 +++++++++++ packages/core/src/session/runner/llm.ts | 8 +- .../test/session-runner-attachment.test.ts | 163 ++++++++++++++++++ packages/core/test/session-runner.test.ts | 38 ++++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/session/runner/attachment.ts create mode 100644 packages/core/test/session-runner-attachment.test.ts diff --git a/packages/core/src/session/runner/attachment.ts b/packages/core/src/session/runner/attachment.ts new file mode 100644 index 0000000000..8c7546a99f --- /dev/null +++ b/packages/core/src/session/runner/attachment.ts @@ -0,0 +1,101 @@ +export * as SessionRunnerAttachment from "./attachment" + +import { fileURLToPath } from "url" +import { Effect } from "effect" +import { AbsolutePath } from "../../schema" +import { ReadToolFileSystem } from "../../tool/read-filesystem" +import { SessionMessage } from "../message" +import type { FileAttachment } from "../prompt" + +/** + * Materialize local `file:` attachments during per-turn request assembly. + * + * Providers accept media content only for a narrow set of mimes, so lowering an + * unresolved `file:` URI (or an `application/x-directory` attachment) as a media + * part fails the provider turn. Directories become an inline listing, text files + * become inline content, and images are re-encoded as data URLs. Other URI + * schemes (data URLs, MCP resources) pass through unchanged, and unreadable + * attachments degrade to a model-visible note instead of failing the turn. The + * durable projected message is never modified. + */ +export const materialize = Effect.fn("SessionRunnerAttachment.materialize")(function* ( + reader: ReadToolFileSystem.Interface, + messages: readonly SessionMessage.Message[], +) { + return yield* Effect.forEach(messages, (message) => { + if (message.type !== "user" || !message.files?.some(local)) return Effect.succeed(message) + return Effect.forEach(message.files, (file) => materializeFile(reader, file)).pipe( + Effect.map((results) => + SessionMessage.User.make({ + ...message, + text: [ + message.text, + ...results.flatMap((result) => (result.expansion === undefined ? [] : [result.expansion])), + ].join("\n\n"), + files: results.flatMap((result) => (result.file === undefined ? [] : [result.file])), + }), + ), + ) + }) +}) + +const local = (file: FileAttachment) => file.uri.startsWith("file:") + +interface Materialized { + readonly file?: FileAttachment + readonly expansion?: string +} + +const wrap = (tag: string, path: string, body: string) => `<${tag} path=${JSON.stringify(path)}>\n${body}\n` + +const unavailable = (path: string, reason: string): Materialized => ({ + expansion: wrap("attachment-unavailable", path, reason), +}) + +// Mirror V1's `?start`/`?end` line-range attachment parameters. +const pageFromRange = (url: URL) => { + const start = url.searchParams.get("start") + if (start === null) return undefined + const offset = Math.max(parseInt(start, 10) || 1, 1) + const end = url.searchParams.get("end") + const parsedEnd = end === null ? Number.NaN : parseInt(end, 10) + return { offset, ...(parsedEnd >= offset ? { limit: parsedEnd - offset + 1 } : {}) } +} + +const materializeFile = (reader: ReadToolFileSystem.Interface, file: FileAttachment) => { + if (!local(file)) return Effect.succeed({ file }) + const resolved = Effect.try({ + try: () => { + const url = new URL(file.uri) + const page = pageFromRange(url) + url.search = "" + url.hash = "" + return { target: AbsolutePath.make(fileURLToPath(url)), page } + }, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + return Effect.gen(function* () { + const { target, page } = yield* resolved + const display = file.name ?? target + const kind = yield* reader.inspect(target) + if (kind === "directory") { + const listing = yield* reader.list(target) + const lines = [ + ...listing.entries.map((entry) => entry.path), + ...(listing.truncated ? ["(listing truncated)"] : []), + ] + return { expansion: wrap("attached-directory", display, lines.join("\n")) } satisfies Materialized + } + const content = yield* reader.read(target, display, page) + if ("encoding" in content && content.encoding === "base64") + return { + file: { ...file, uri: `data:${content.mime};base64,${content.content}`, mime: content.mime }, + } satisfies Materialized + const truncated = "truncated" in content && content.truncated ? "\n(content truncated)" : "" + return { expansion: wrap("attached-file", display, content.content + truncated) } satisfies Materialized + }).pipe( + Effect.catch((error) => + Effect.succeed(unavailable(file.name ?? file.uri, error instanceof Error ? error.message : String(error))), + ), + ) +} diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index f3f64fe266..f7f3b420af 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -23,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" import { McpGuidance } from "../../mcp/guidance" import { ToolRegistry } from "../../tool/registry" +import { ReadToolFileSystem } from "../../tool/read-filesystem" import { ToolOutputStore } from "../../tool-output-store" import { SessionContextEpoch } from "../context-epoch" import { SessionCompaction } from "../compaction" @@ -33,6 +34,7 @@ import { SessionSchema } from "../schema" import { SessionStore } from "../store" import { SessionTitle } from "../title" import { type RunError, Service } from "./index" +import { SessionRunnerAttachment } from "./attachment" import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" @@ -99,6 +101,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service + const reader = yield* ReadToolFileSystem.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -203,6 +206,8 @@ const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) + // Expand local file/directory attachments for this request only; durable history keeps the URIs. + const materialized = yield* SessionRunnerAttachment.materialize(reader, context) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined @@ -214,7 +219,7 @@ const layer = Layer.effect( system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + messages: [...toLLMMessages(materialized, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) @@ -432,6 +437,7 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, + ReadToolFileSystem.node, SessionRunnerModel.node, SessionStore.node, Location.node, diff --git a/packages/core/test/session-runner-attachment.test.ts b/packages/core/test/session-runner-attachment.test.ts new file mode 100644 index 0000000000..ccc7e69f3e --- /dev/null +++ b/packages/core/test/session-runner-attachment.test.ts @@ -0,0 +1,163 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { DateTime, Effect, FileSystem } from "effect" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { FileAttachment } from "@opencode-ai/core/session/prompt" +import { SessionRunnerAttachment } from "@opencode-ai/core/session/runner/attachment" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(LayerNode.group([ReadToolFileSystem.node, LayerNodePlatform.filesystem]))) + +const created = DateTime.makeUnsafe(0) +const user = (files: FileAttachment[]) => + SessionMessage.User.make({ + id: SessionMessage.ID.make("msg_user"), + type: "user", + text: "Look at this", + files, + time: { created }, + }) + +const fixture = Effect.gen(function* () { + const reader = yield* ReadToolFileSystem.Service + const files = yield* FileSystem.FileSystem + const directory = yield* files.makeTempDirectoryScoped() + return { reader, files, directory } +}) + +const requireUser = (message: SessionMessage.Message) => { + if (message.type !== "user") throw new Error(`Expected a user message, got ${message.type}`) + return message +} + +describe("SessionRunnerAttachment.materialize", () => { + it.effect("expands a directory attachment into a listing instead of media", () => + Effect.gen(function* () { + const { reader, files, directory } = yield* fixture + yield* files.makeDirectory(path.join(directory, "src")) + yield* files.writeFileString(path.join(directory, "package.json"), "{}") + const attachment = FileAttachment.make({ + uri: pathToFileURL(directory + path.sep).href, + mime: "application/x-directory", + name: "project/", + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [user([attachment])]) + + const message = requireUser(result[0]) + expect(message.files).toEqual([]) + expect(message.text).toContain('') + expect(message.text).toContain("src/") + expect(message.text).toContain("package.json") + }), + ) + + it.effect("expands a text file attachment into inline content", () => + Effect.gen(function* () { + const { reader, files, directory } = yield* fixture + const file = path.join(directory, "notes.md") + yield* files.writeFileString(file, "first line\nsecond line\nthird line\n") + const attachment = FileAttachment.make({ + uri: pathToFileURL(file).href, + mime: "text/markdown", + name: "notes.md", + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [user([attachment])]) + + const message = requireUser(result[0]) + expect(message.files).toEqual([]) + expect(message.text).toContain('') + expect(message.text).toContain("second line") + }), + ) + + it.effect("honors ?start/?end line-range parameters", () => + Effect.gen(function* () { + const { reader, files, directory } = yield* fixture + const file = path.join(directory, "notes.md") + yield* files.writeFileString(file, "first line\nsecond line\nthird line\nfourth line\n") + const attachment = FileAttachment.make({ + uri: pathToFileURL(file).href + "?start=2&end=3", + mime: "text/markdown", + name: "notes.md#2-3", + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [user([attachment])]) + + const message = requireUser(result[0]) + expect(message.text).toContain("second line") + expect(message.text).toContain("third line") + expect(message.text).not.toContain("first line") + expect(message.text).not.toContain("fourth line") + }), + ) + + it.effect("re-encodes an image attachment as a data URL media part", () => + Effect.gen(function* () { + const { reader, files, directory } = yield* fixture + const file = path.join(directory, "pixel.png") + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]) + yield* files.writeFile(file, png) + const attachment = FileAttachment.make({ + uri: pathToFileURL(file).href, + mime: "image/png", + name: "pixel.png", + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [user([attachment])]) + + const message = requireUser(result[0]) + expect(message.text).toBe("Look at this") + expect(message.files).toHaveLength(1) + expect(message.files![0].mime).toBe("image/png") + expect(message.files![0].uri).toBe(`data:image/png;base64,${Buffer.from(png).toString("base64")}`) + }), + ) + + it.effect("degrades unreadable attachments to a model-visible note instead of failing", () => + Effect.gen(function* () { + const { reader, directory } = yield* fixture + const attachment = FileAttachment.make({ + uri: pathToFileURL(path.join(directory, "missing.txt")).href, + mime: "text/plain", + name: "missing.txt", + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [user([attachment])]) + + const message = requireUser(result[0]) + expect(message.files).toEqual([]) + expect(message.text).toContain('') + }), + ) + + it.effect("passes data URLs and non-user messages through unchanged", () => + Effect.gen(function* () { + const { reader } = yield* fixture + const dataAttachment = FileAttachment.make({ + uri: "data:image/png;base64,aGVsbG8=", + mime: "image/png", + name: "hello.png", + }) + const original = user([dataAttachment]) + const synthetic = SessionMessage.Synthetic.make({ + id: SessionMessage.ID.make("msg_synthetic"), + type: "synthetic", + sessionID: SessionV2.ID.make("ses_translate"), + text: "Synthetic context", + time: { created }, + }) + + const result = yield* SessionRunnerAttachment.materialize(reader, [original, synthetic]) + + expect(result[0]).toBe(original) + expect(result[1]).toBe(synthetic) + }), + ) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index b2631226e0..cf35dd6cb8 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,4 +1,8 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" import { LLMClient, LLMError, @@ -680,6 +684,40 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("materializes a directory attachment as text instead of provider media", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const directory = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-attach-"))) + yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(directory, { recursive: true, force: true }))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "nested.txt"), "hello")) + requests.length = 0 + + yield* session.prompt({ + sessionID, + prompt: Prompt.make({ + text: "Inspect the attachment", + files: [ + { uri: pathToFileURL(directory + path.sep).href, mime: "application/x-directory", name: "fixtures/" }, + ], + }), + resume: false, + }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + const message = requests[0].messages.find((item) => item.role === "user") + expect(message?.content.some((part) => part.type === "media")).toBe(false) + const text = userTexts(requests[0]).join("\n") + expect(text).toContain('') + expect(text).toContain("nested.txt") + // Durable projection keeps the original attachment URI; only the request is expanded. + expect(yield* session.messages({ sessionID })).toMatchObject([ + { type: "user", files: [{ mime: "application/x-directory" }] }, + ]) + }), + ) + it.effect("retries the first provider turn after system context becomes available", () => Effect.gen(function* () { yield* setup