Compare commits

...

5 Commits

14 changed files with 456 additions and 35 deletions
+6
View File
@@ -889,6 +889,7 @@ export type SessionContextOutput = {
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
readonly resolved?: string
}>
readonly agents?: ReadonlyArray<{
readonly name: string
@@ -1139,6 +1140,7 @@ export type SessionHistoryOutput = {
}>
}
readonly delivery: "steer" | "queue"
readonly resolutions?: ReadonlyArray<{ readonly uri: string; readonly resolved: string }>
}
}
| {
@@ -1634,6 +1636,7 @@ export type SessionEventsOutput =
}>
}
readonly delivery: "steer" | "queue"
readonly resolutions?: ReadonlyArray<{ readonly uri: string; readonly resolved: string }>
}
}
| {
@@ -2075,6 +2078,7 @@ export type SessionMessageOutput = {
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
readonly resolved?: string
}>
readonly agents?: ReadonlyArray<{
readonly name: string
@@ -2256,6 +2260,7 @@ export type MessageListOutput = {
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
readonly resolved?: string
}>
readonly agents?: ReadonlyArray<{
readonly name: string
@@ -3802,6 +3807,7 @@ export type EventSubscribeOutput =
}>
}
readonly delivery: "steer" | "queue"
readonly resolutions?: ReadonlyArray<{ readonly uri: string; readonly resolved: string }>
}
}
| {
+7 -1
View File
@@ -108,7 +108,13 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
// Resolved text attachments carry the content the model actually saw; media stays a placeholder.
const files =
message.files?.map((file) =>
file.resolved !== undefined && !file.resolved.startsWith("data:")
? truncate(file.resolved)
: `[Attached ${file.mime}: ${file.name ?? file.uri}]`,
) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "assistant") {
+19 -3
View File
@@ -213,21 +213,35 @@ const matchesProjection = (
equivalent(input, expected) &&
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
/**
* Captures model-visible attachment content while an input is promoted, so
* projection replay stays deterministic without filesystem access. Resolution
* must not fail; unreadable attachments resolve to a model-visible note.
*/
export type Resolver = (prompt: Prompt) => Effect.Effect<ReadonlyArray<SessionEvent.AttachmentResolution>>
/** Promote without capturing attachment content, e.g. in tests without a Location filesystem. */
export const unresolved: Resolver = () => Effect.succeed([])
const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
resolve: Resolver,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) {
for (const row of rows) {
const id = SessionMessage.ID.make(row.id)
const prompt = decodePrompt(row.prompt)
const resolutions = yield* resolve(prompt)
yield* events
.publish(SessionEvent.Prompted, {
sessionID,
timestamp: DateTime.makeUnsafe(row.time_created),
messageID: id,
prompt: decodePrompt(row.prompt),
prompt,
delivery: row.delivery,
...(resolutions.length === 0 ? {} : { resolutions }),
})
.pipe(
Effect.catchDefect((defect) =>
@@ -247,6 +261,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
events: EventV2.Interface,
sessionID: SessionSchema.ID,
cutoff: number,
resolve: Resolver,
) {
const rows = yield* db
.select()
@@ -262,13 +277,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
.orderBy(asc(SessionInputTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return yield* publish(db, events, sessionID, rows)
return yield* publish(db, events, sessionID, resolve, rows)
})
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
resolve: Resolver,
) {
const row = yield* db
.select()
@@ -284,5 +300,5 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
.limit(1)
.get()
.pipe(Effect.orDie)
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
return row === undefined ? false : yield* publish(db, events, sessionID, resolve, [row]).pipe(Effect.as(true))
})
+5 -1
View File
@@ -126,13 +126,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.renamed": () => Effect.void,
"session.next.forked": () => Effect.void,
"session.next.prompted": (event) => {
const resolved = new Map(event.data.resolutions?.map((item) => [item.uri, item.resolved]))
return adapter.appendMessage(
SessionMessage.User.make({
id: event.data.messageID,
type: "user",
metadata: event.metadata,
text: event.data.prompt.text,
files: event.data.prompt.files,
files: event.data.prompt.files?.map((file) => {
const content = resolved.get(file.uri)
return content === undefined ? file : { ...file, resolved: content }
}),
agents: event.data.prompt.agents,
time: { created: event.data.timestamp },
}),
@@ -0,0 +1,88 @@
export * as SessionRunnerAttachment from "./attachment"
import { fileURLToPath } from "url"
import { Effect } from "effect"
import { Image } from "../../image"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../../tool/read-filesystem"
import { SessionEvent } from "../event"
import type { FileAttachment, Prompt } from "../prompt"
export interface Services {
readonly reader: ReadToolFileSystem.Interface
readonly image: Image.Interface
}
/**
* Capture model-visible content for a prompt's local `file:` attachments at
* promotion time, so the durable user message snapshots what the user attached.
*
* 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 resolve to an inline listing, text
* files to inline content, and images to normalized data URLs. Every result is
* bounded: reads cap at `MAX_READ_BYTES`/`MAX_READ_LINES`, listings at
* `MAX_READ_LINES` entries, and images at the configured normalization limit.
* Other URI schemes (data URLs, MCP resources) are skipped, and unreadable
* attachments resolve to a model-visible note instead of failing promotion.
*/
export const resolutions = Effect.fn("SessionRunnerAttachment.resolutions")(function* (
services: Services,
prompt: Prompt,
) {
const locals = (prompt.files ?? []).filter(local)
const unique = [...new Map(locals.map((file) => [file.uri, file])).values()]
return yield* Effect.forEach(unique, (file) =>
resolve(services, file).pipe(
Effect.map((resolved) => SessionEvent.AttachmentResolution.make({ uri: file.uri, resolved })),
),
)
})
const local = (file: FileAttachment) => file.uri.startsWith("file:")
const wrap = (tag: string, path: string, body: string) => `<${tag} path=${JSON.stringify(path)}>\n${body}\n</${tag}>`
// Mirror V1's `?start`/`?end` line-range attachment parameters.
const pageFromRange = (url: URL) => {
const start = parseInt(url.searchParams.get("start") ?? "", 10)
if (!Number.isInteger(start) || start < 1) return undefined
const end = parseInt(url.searchParams.get("end") ?? "", 10)
return { offset: start, ...(end >= start ? { limit: end - start + 1 } : {}) }
}
const resolve = (services: Services, file: FileAttachment) =>
Effect.gen(function* () {
const { target, page } = yield* 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))),
})
const display = file.name ?? target
const kind = yield* services.reader.inspect(target)
if (kind === "directory") {
const listing = yield* services.reader.list(target)
const lines = [
...listing.entries.map((entry) => entry.path),
...(listing.truncated ? ["(listing truncated)"] : []),
]
return wrap("attached-directory", display, lines.join("\n"))
}
const content = yield* services.reader.read(target, display, page)
if (content instanceof ReadToolFileSystem.TextPage) {
const truncated = content.truncated ? "\n(content truncated)" : ""
return wrap("attached-file", display, content.content + truncated)
}
if (content.encoding === "base64") {
const normalized = yield* services.image
.normalize(display, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
return `data:${normalized.mime};base64,${normalized.content}`
}
return wrap("attached-file", display, content.content)
}).pipe(Effect.catch((error) => Effect.succeed(wrap("attachment-unavailable", file.name ?? file.uri, error.message))))
+15 -3
View File
@@ -12,6 +12,7 @@ import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stre
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { Image } from "../../image"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
@@ -25,6 +26,7 @@ import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { SessionContextEntry } from "../context-entry"
import { ToolRegistry } from "../../tool/registry"
import { ReadToolFileSystem } from "../../tool/read-filesystem"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextCheckpoint } from "../context-checkpoint"
import { SessionCompaction } from "../compaction"
@@ -35,6 +37,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"
@@ -101,6 +104,12 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const attachments: SessionRunnerAttachment.Services = {
reader: yield* ReadToolFileSystem.Service,
image: yield* Image.Service,
}
const resolveAttachments: SessionInput.Resolver = (prompt) =>
SessionRunnerAttachment.resolutions(attachments, prompt)
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
@@ -210,10 +219,11 @@ const layer = Layer.effect(
if (promotion) {
const cutoff = yield* EventV2.latestSequence(db, session.id)
let promoted = 0
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
if (promotion === "steer")
promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff, resolveAttachments)
if (promotion === "queue") {
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id, resolveAttachments))
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff, resolveAttachments)
}
if (promoted > 0) currentStep = 1
}
@@ -476,6 +486,8 @@ export const node = makeLocationNode({
llmClient,
AgentV2.node,
ToolRegistry.node,
ReadToolFileSystem.node,
Image.node,
SessionRunnerModel.node,
SessionStore.node,
Location.node,
@@ -8,15 +8,27 @@ import {
type ProviderMetadata,
} from "@opencode-ai/llm"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
data: file.uri,
filename: file.name,
metadata: file.description === undefined ? undefined : { description: file.description },
})
// Attachments carry promotion-time `resolved` content: a data URL for media,
// model-visible text otherwise. Unresolved `file:` URIs cannot be lowered as
// media (providers reject the mime or the non-data payload), so they degrade
// to a model-visible note instead of failing the provider turn.
const attachment = (file: SessionMessage.UserFile): ContentPart => {
if (file.resolved !== undefined && !file.resolved.startsWith("data:")) return { type: "text", text: file.resolved }
const uri = file.resolved ?? file.uri
if (uri.startsWith("file:"))
return {
type: "text",
text: `<attachment-unavailable path=${JSON.stringify(file.name ?? file.uri)}>\nAttachment was not captured; read it with tools if needed.\n</attachment-unavailable>`,
}
return {
type: "media",
mediaType: uri.match(/^data:([^;,]+)[;,]/i)?.[1] ?? file.mime,
data: uri,
filename: file.name,
metadata: file.description === undefined ? undefined : { description: file.description },
}
}
const toolInput = (tool: SessionMessage.AssistantTool) => {
if (tool.state.status !== "pending") return tool.state.input
@@ -122,7 +134,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(attachment)],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
+13 -7
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)
+59 -5
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")
@@ -499,6 +499,60 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("reconciles an exact retry after promotion captured attachment resolutions", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const uri = "file:///project/notes.md"
const prompt = Prompt.make({
text: "Look at this",
files: [{ uri, mime: "text/plain", name: "notes.md" }],
})
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER, () =>
Effect.succeed([{ uri, resolved: '<attached-file path="notes.md">\ncontent\n</attached-file>' }]),
)
// The projected message snapshots the resolution; the admitted prompt stays original.
expect(yield* session.messages({ sessionID })).toMatchObject([
{ type: "user", files: [{ uri, resolved: expect.stringContaining("attached-file") }] },
])
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
expect(retried).toMatchObject({ id: messageID, prompt: { files: [{ uri }] } })
}),
)
it.effect("reconciles an exact retry of a legacy prompt whose event carried resolutions", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const uri = "file:///project/notes.md"
// Mime must match admission-time `resolvePrompt` normalization for the retry to be exact.
const prompt = Prompt.make({
text: "Look at this",
files: [{ uri, mime: "text/markdown", name: "notes.md" }],
})
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
prompt,
delivery: "steer",
resolutions: [{ uri, resolved: '<attached-file path="notes.md">\ncontent\n</attached-file>' }],
})
// Lazy synthesis must build the inbox record from the original prompt, not the resolved message.
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
expect(retried).toMatchObject({ id: messageID, prompt: { files: [{ uri }] } })
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
}),
)
it.effect("returns an exact retry of a legacy projected prompt", () =>
Effect.gen(function* () {
yield* setup
@@ -0,0 +1,133 @@
import { describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
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 { 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])))
// The resizer-unavailable stub exercises the raw-content fallback deterministically.
const image = Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) })
const fixture = Effect.gen(function* () {
const services = { reader: yield* ReadToolFileSystem.Service, image }
const files = yield* FileSystem.FileSystem
const directory = yield* files.makeTempDirectoryScoped()
return { services, files, directory }
})
const prompt = (files: NonNullable<Prompt["files"]>) => Prompt.make({ text: "Look at this", files })
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 uri = pathToFileURL(directory + path.sep).href
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri, mime: "application/x-directory", name: "project/" }]),
)
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("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 result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href, mime: "text/markdown", name: "notes.md" }]),
)
expect(result[0].resolved).toContain('<attached-file path="notes.md">')
expect(result[0].resolved).toContain("second line")
}),
)
it.effect("honors ?start/?end line-range parameters", () =>
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\nfourth line\n")
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href + "?start=2&end=3", mime: "text/markdown", name: "notes.md#2-3" }]),
)
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("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 result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([{ uri: pathToFileURL(file).href, mime: "image/png", name: "pixel.png" }]),
)
expect(result[0].resolved).toBe(`data:image/png;base64,${Buffer.from(png).toString("base64")}`)
}),
)
it.effect("resolves unreadable attachments to a model-visible note instead of failing", () =>
Effect.gen(function* () {
const { services, directory } = yield* fixture
const result = yield* SessionRunnerAttachment.resolutions(
services,
prompt([
{ uri: pathToFileURL(path.join(directory, "missing.txt")).href, mime: "text/plain", name: "missing.txt" },
]),
)
expect(result[0].resolved).toContain('<attachment-unavailable path="missing.txt">')
}),
)
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, "content\n")
const uri = pathToFileURL(file).href
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(result).toHaveLength(1)
expect(result[0].uri).toBe(uri)
}),
)
})
+62 -3
View File
@@ -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,
@@ -678,6 +682,43 @@ 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")
// 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")
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
yield* setup
@@ -2381,7 +2422,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,
@@ -2445,7 +2492,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,
@@ -2505,7 +2558,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,
+15 -1
View File
@@ -105,10 +105,24 @@ export const Forked = Event.define({
})
export type Forked = typeof Forked.Type
/**
* Model-visible content captured for one attachment URI at promotion time:
* a data URL for media, otherwise text. Recorded on the event so projection
* replay stays deterministic without filesystem access.
*/
export const AttachmentResolution = Schema.Struct({
uri: Schema.String,
resolved: Schema.String,
}).annotate({ identifier: "session.next.event.attachment-resolution" })
export interface AttachmentResolution extends Schema.Schema.Type<typeof AttachmentResolution> {}
export const Prompted = Event.define({
type: "session.next.prompted",
...options,
schema: PromptFields,
schema: {
...PromptFields,
resolutions: Schema.Array(AttachmentResolution).pipe(optional),
},
})
export type Prompted = typeof Prompted.Type
+12 -1
View File
@@ -41,11 +41,22 @@ export const ModelSwitched = Schema.Struct({
model: Model.Ref,
}).annotate({ identifier: "Session.Message.ModelSwitched" })
/**
* A prompt attachment plus the model-visible content captured for it at
* promotion time: a data URL for media, otherwise text. The original `uri`
* and `mime` are preserved for provenance; only `resolved` is server-produced.
*/
export interface UserFile extends Schema.Schema.Type<typeof UserFile> {}
export const UserFile = Schema.Struct({
...FileAttachment.fields,
resolved: Schema.String.pipe(optional),
}).annotate({ identifier: "Session.Message.UserFile" })
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
text: Prompt.fields.text,
files: Prompt.fields.files,
files: Schema.Array(UserFile).pipe(optional),
agents: Prompt.fields.agents,
type: Schema.Literal("user"),
}).annotate({ identifier: "Session.Message.User" })
+1 -1
View File
@@ -145,7 +145,7 @@ Status: `complete` is usable in the native V2 path, `partial` covers only part o
| Per-turn request assembly | Automatic/context-pressure compaction | complete | V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint. |
| Prompt/reference expansion | Durable typed prompt attachments | complete | None. |
| Prompt/reference expansion | Native template and `@` mention expansion | missing | Parse and resolve native V2 prompt input before durable admission. |
| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Materialize and normalize sources instead of lowering unresolved attachment metadata. |
| Prompt/reference expansion | File, directory, media, and MCP-resource materialization | partial | Local file, directory, and image attachments resolve durably at promotion (`Prompted.resolutions`); MCP-resource capture remains. |
| Prompt/reference expansion | Agent-reference expansion | missing | Produce permission-aware model-visible task guidance. |
| Prompt/reference expansion | Configured-reference expansion | missing | Resolve aliases and emit durable model-visible reference context or failures. |
| Prompt/reference expansion | Native synthetic expansion replay | partial | V2 replays synthetic messages but only the V1 compatibility path creates them. |