refactor(core): resolve attachments durably at promotion

This commit is contained in:
Kit Langton 2026-07-02 09:48:02 -04:00
commit 994f55423a
14 changed files with 252 additions and 252 deletions

View file

@ -135,7 +135,7 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
messageID: SessionMessage.ID.create(),
@ -167,9 +167,9 @@ describe("SessionV2.create", () => {
})
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@ -192,13 +192,13 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
const second = yield* session.prompt({
sessionID: parent.id,
prompt: Prompt.make({ text: "Second" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
@ -314,7 +314,7 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
@ -336,7 +336,13 @@ describe("SessionV2.create", () => {
prompt: Prompt.make({ text: "Replay lifecycle" }),
resume: false,
})
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(
sourceDb,
sourceEvents,
created.id,
Number.MAX_SAFE_INTEGER,
SessionInput.unresolved,
)
const serialized = (yield* sourceDb
.select()
.from(EventTable)

View file

@ -197,7 +197,7 @@ describe("SessionV2.prompt", () => {
prompt: Prompt.make({ text: "boundary" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@ -250,7 +250,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER, SessionInput.unresolved)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
@ -425,8 +425,8 @@ describe("SessionV2.prompt", () => {
yield* Effect.all(
[
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER, SessionInput.unresolved),
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER, SessionInput.unresolved),
],
{ concurrency: "unbounded" },
)
@ -449,7 +449,7 @@ describe("SessionV2.prompt", () => {
const cutoff = first.admittedSeq
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff, SessionInput.unresolved)
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")

View file

@ -1,29 +1,17 @@
import { describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { DateTime, Effect, FileSystem } from "effect"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { FileAttachment } from "@opencode-ai/core/session/prompt"
import { Prompt } 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 },
})
// The resizer-unavailable stub exercises the raw-content fallback deterministically.
const image = Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) })
@ -34,50 +22,42 @@ const fixture = Effect.gen(function* () {
return { services, files, directory }
})
const requireUser = (message: SessionMessage.Message) => {
if (message.type !== "user") throw new Error(`Expected a user message, got ${message.type}`)
return message
}
const prompt = (files: NonNullable<Prompt["files"]>) => Prompt.make({ text: "Look at this", files })
describe("SessionRunnerAttachment.materialize", () => {
it.effect("expands a directory attachment into a listing instead of media", () =>
describe("SessionRunnerAttachment.resolutions", () => {
it.effect("resolves a directory attachment to a listing", () =>
Effect.gen(function* () {
const { services, 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 uri = pathToFileURL(directory + path.sep).href
const result = yield* SessionRunnerAttachment.materialize(services, new Map(), [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri, mime: "application/x-directory", name: "project/" }]),
)
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")
expect(result).toHaveLength(1)
expect(result[0].uri).toBe(uri)
expect(result[0].resolved).toContain('<attached-directory path="project/">')
expect(result[0].resolved).toContain("src/")
expect(result[0].resolved).toContain("package.json")
}),
)
it.effect("expands a text file attachment into inline content", () =>
it.effect("resolves a text file attachment to inline content", () =>
Effect.gen(function* () {
const { services, 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(services, new Map(), [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href, mime: "text/markdown", name: "notes.md" }]),
)
const message = requireUser(result[0])
expect(message.files).toEqual([])
expect(message.text).toContain('<attached-file path="notes.md">')
expect(message.text).toContain("second line")
expect(result[0].resolved).toContain('<attached-file path="notes.md">')
expect(result[0].resolved).toContain("second line")
}),
)
@ -86,103 +66,68 @@ describe("SessionRunnerAttachment.materialize", () => {
const { services, 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(services, new Map(), [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href + "?start=2&end=3", mime: "text/markdown", name: "notes.md#2-3" }]),
)
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")
expect(result[0].resolved).toContain("second line")
expect(result[0].resolved).toContain("third line")
expect(result[0].resolved).not.toContain("first line")
expect(result[0].resolved).not.toContain("fourth line")
}),
)
it.effect("re-encodes an image attachment as a data URL media part", () =>
it.effect("resolves an image attachment to a data URL", () =>
Effect.gen(function* () {
const { services, 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(services, new Map(), [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href, mime: "image/png", name: "pixel.png" }]),
)
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")}`)
expect(result[0].resolved).toBe(`data:image/png;base64,${Buffer.from(png).toString("base64")}`)
}),
)
it.effect("degrades unreadable attachments to a model-visible note instead of failing", () =>
it.effect("resolves unreadable attachments to a model-visible note instead of failing", () =>
Effect.gen(function* () {
const { services, 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(services, new Map(), [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([
{ uri: pathToFileURL(path.join(directory, "missing.txt")).href, mime: "text/plain", name: "missing.txt" },
]),
)
const message = requireUser(result[0])
expect(message.files).toEqual([])
expect(message.text).toContain('<attachment-unavailable path="missing.txt">')
expect(result[0].resolved).toContain('<attachment-unavailable path="missing.txt">')
}),
)
it.effect("reuses cached materialization for the life of a drain", () =>
it.effect("skips data URLs and deduplicates repeated URIs", () =>
Effect.gen(function* () {
const { services, files, directory } = yield* fixture
const file = path.join(directory, "notes.md")
yield* files.writeFileString(file, "original content\n")
const attachment = FileAttachment.make({
uri: pathToFileURL(file).href,
mime: "text/plain",
name: "notes.md",
})
const cache: SessionRunnerAttachment.Cache = new Map()
yield* files.writeFileString(file, "content\n")
const uri = pathToFileURL(file).href
const first = yield* SessionRunnerAttachment.materialize(services, cache, [user([attachment])])
yield* files.writeFileString(file, "changed content\n")
const second = yield* SessionRunnerAttachment.materialize(services, cache, [user([attachment])])
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([
{ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
{ uri, mime: "text/plain", name: "notes.md" },
{ uri, mime: "text/plain", name: "notes.md" },
]),
)
expect(requireUser(second[0]).text).toBe(requireUser(first[0]).text)
expect(requireUser(second[0]).text).toContain("original content")
}),
)
it.effect("passes data URLs and non-user messages through unchanged", () =>
Effect.gen(function* () {
const { services } = 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(services, new Map(), [original, synthetic])
expect(result[0]).toBe(original)
expect(result[1]).toBe(synthetic)
expect(result).toHaveLength(1)
expect(result[0].uri).toBe(uri)
}),
)
})

View file

@ -711,10 +711,13 @@ describe("SessionRunnerLLM", () => {
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" }] },
])
// The durable projection keeps the original URI and snapshots the resolved listing.
const messages = yield* session.messages({ sessionID })
expect(messages).toMatchObject([{ type: "user", files: [{ mime: "application/x-directory" }] }])
const stored = messages[0]
if (stored?.type !== "user") throw new Error("Expected a user message")
expect(stored.files?.[0]?.uri.startsWith("file:")).toBe(true)
expect(stored.files?.[0]?.resolved).toContain("nested.txt")
}),
)
@ -2330,7 +2333,13 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(
(yield* Database.Service).db,
events,
sessionID,
Number.MAX_SAFE_INTEGER,
SessionInput.unresolved,
)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@ -2394,7 +2403,13 @@ describe("SessionRunnerLLM", () => {
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(
(yield* Database.Service).db,
events,
sessionID,
Number.MAX_SAFE_INTEGER,
SessionInput.unresolved,
)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
@ -2454,7 +2469,13 @@ describe("SessionRunnerLLM", () => {
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
yield* SessionInput.promoteSteers(
(yield* Database.Service).db,
events,
sessionID,
Number.MAX_SAFE_INTEGER,
SessionInput.unresolved,
)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,