fix(core): materialize file and directory attachments before provider lowering
This commit is contained in:
parent
bea6e1499d
commit
28de367444
4 changed files with 309 additions and 1 deletions
101
packages/core/src/session/runner/attachment.ts
Normal file
101
packages/core/src/session/runner/attachment.ts
Normal file
|
|
@ -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</${tag}>`
|
||||||
|
|
||||||
|
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<Materialized>({ 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))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { SkillGuidance } from "../../skill/guidance"
|
||||||
import { ReferenceGuidance } from "../../reference/guidance"
|
import { ReferenceGuidance } from "../../reference/guidance"
|
||||||
import { McpGuidance } from "../../mcp/guidance"
|
import { McpGuidance } from "../../mcp/guidance"
|
||||||
import { ToolRegistry } from "../../tool/registry"
|
import { ToolRegistry } from "../../tool/registry"
|
||||||
|
import { ReadToolFileSystem } from "../../tool/read-filesystem"
|
||||||
import { ToolOutputStore } from "../../tool-output-store"
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
import { SessionContextEpoch } from "../context-epoch"
|
import { SessionContextEpoch } from "../context-epoch"
|
||||||
import { SessionCompaction } from "../compaction"
|
import { SessionCompaction } from "../compaction"
|
||||||
|
|
@ -33,6 +34,7 @@ import { SessionSchema } from "../schema"
|
||||||
import { SessionStore } from "../store"
|
import { SessionStore } from "../store"
|
||||||
import { SessionTitle } from "../title"
|
import { SessionTitle } from "../title"
|
||||||
import { type RunError, Service } from "./index"
|
import { type RunError, Service } from "./index"
|
||||||
|
import { SessionRunnerAttachment } from "./attachment"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||||
import { toLLMMessages } from "./to-llm-message"
|
import { toLLMMessages } from "./to-llm-message"
|
||||||
|
|
@ -99,6 +101,7 @@ const layer = Layer.effect(
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const tools = yield* ToolRegistry.Service
|
const tools = yield* ToolRegistry.Service
|
||||||
|
const reader = yield* ReadToolFileSystem.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
|
@ -203,6 +206,8 @@ const layer = Layer.effect(
|
||||||
const model = yield* models.resolve(session)
|
const model = yield* models.resolve(session)
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
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 isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||||
const toolMaterialization = isLastStep
|
const toolMaterialization = isLastStep
|
||||||
? undefined
|
? undefined
|
||||||
|
|
@ -214,7 +219,7 @@ const layer = Layer.effect(
|
||||||
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
||||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||||
.map(SystemPart.make),
|
.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 ?? [],
|
tools: toolMaterialization?.definitions ?? [],
|
||||||
toolChoice: isLastStep ? "none" : undefined,
|
toolChoice: isLastStep ? "none" : undefined,
|
||||||
})
|
})
|
||||||
|
|
@ -432,6 +437,7 @@ export const node = makeLocationNode({
|
||||||
llmClient,
|
llmClient,
|
||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
ToolRegistry.node,
|
ToolRegistry.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
|
|
|
||||||
163
packages/core/test/session-runner-attachment.test.ts
Normal file
163
packages/core/test/session-runner-attachment.test.ts
Normal file
|
|
@ -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('<attached-directory path="project/">')
|
||||||
|
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('<attached-file path="notes.md">')
|
||||||
|
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('<attachment-unavailable path="missing.txt">')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
import {
|
import {
|
||||||
LLMClient,
|
LLMClient,
|
||||||
LLMError,
|
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('<attached-directory path="fixtures/">')
|
||||||
|
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", () =>
|
it.effect("retries the first provider turn after system context becomes available", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue