From 4d100de1944764f6d2dbfbeafb859655b45832ca Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 6 Jul 2026 15:04:23 -0400 Subject: [PATCH] feat(core): resolve prompt attachments --- .../client/src/promise/generated/client.ts | 4 +- .../client/src/promise/generated/types.ts | 126 +++++----- .../client/test/contract-identity.test.ts | 2 - packages/core/src/mime.ts | 56 +---- packages/core/src/session.ts | 117 +++++++-- packages/core/src/session/compaction.ts | 6 +- packages/core/src/session/input.ts | 2 +- packages/core/src/session/prompt.ts | 1 - .../core/src/session/runner/to-llm-message.ts | 32 +-- packages/core/src/session/sql.ts | 2 +- packages/core/test/session-compact.test.ts | 2 +- packages/core/test/session-create.test.ts | 16 +- packages/core/test/session-projector.test.ts | 2 +- packages/core/test/session-prompt.test.ts | 160 +++++++++--- .../core/test/session-runner-message.test.ts | 62 ++++- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 238 +++++++++--------- packages/core/test/session-title.test.ts | 2 +- packages/core/test/shared-schema.test.ts | 8 +- packages/protocol/src/groups/session.ts | 10 +- packages/schema/src/index.ts | 2 +- packages/schema/src/prompt-input.ts | 4 +- packages/schema/src/prompt.ts | 33 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 50 ++-- packages/server/src/handlers/session.ts | 7 + .../tui/src/component/prompt/autocomplete.tsx | 32 +-- packages/tui/src/component/prompt/index.tsx | 52 ++-- .../session/dialog-fork-from-timeline.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 2 +- 29 files changed, 610 insertions(+), 426 deletions(-) delete mode 100644 packages/core/src/session/prompt.ts diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 929bc2ecb6d..e042859b91d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -477,7 +477,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, successStatus: 200, - declaredStatuses: [409, 404, 400, 401], + declaredStatuses: [409, 400, 404, 401], empty: false, }, requestOptions, @@ -499,7 +499,7 @@ export function make(options: ClientOptions) { resume: input["resume"], }, successStatus: 200, - declaredStatuses: [409, 404, 500, 400, 401], + declaredStatuses: [409, 400, 404, 500, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0f35a342ea7..afe02c11c0b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -528,11 +528,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -546,11 +546,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -564,11 +564,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -582,11 +582,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -602,16 +602,16 @@ export type SessionPromptOutput = { readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery: "steer" | "queue" @@ -632,11 +632,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -651,11 +651,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -670,11 +670,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -689,11 +689,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -708,11 +708,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -727,11 +727,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -746,11 +746,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -765,11 +765,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -784,11 +784,11 @@ export type SessionCommandInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly delivery?: "steer" | "queue" | null readonly resume?: boolean | null @@ -803,16 +803,16 @@ export type SessionCommandOutput = { readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery: "steer" | "queue" @@ -934,16 +934,16 @@ export type SessionContextOutput = { readonly time: { readonly created: number } readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly type: "user" } @@ -1038,12 +1038,12 @@ export type SessionContextOutput = { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } readonly attachments?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } @@ -1198,16 +1198,16 @@ export type SessionLogOutput = readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery: "steer" | "queue" @@ -1631,16 +1631,16 @@ export type SessionMessageOutput = { readonly time: { readonly created: number } readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly type: "user" } @@ -1735,12 +1735,12 @@ export type SessionMessageOutput = { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } readonly attachments?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } @@ -1833,16 +1833,16 @@ export type MessageListOutput = { readonly time: { readonly created: number } readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly type: "user" } @@ -1937,12 +1937,12 @@ export type MessageListOutput = { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } readonly attachments?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } @@ -4485,16 +4485,16 @@ export type EventSubscribeOutput = readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string - readonly content?: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery: "steer" | "queue" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index b2c5cb7c8aa..90ac5d8cfc9 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -7,7 +7,6 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" -import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt" import { Agent } from "@opencode-ai/schema/agent" import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" @@ -32,7 +31,6 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) - expect(CorePrompt).toBe(Prompt) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) diff --git a/packages/core/src/mime.ts b/packages/core/src/mime.ts index 50963d925bf..6ad8de5a605 100644 --- a/packages/core/src/mime.ts +++ b/packages/core/src/mime.ts @@ -1,41 +1,6 @@ export * as Mime from "./mime.js" -import { Effect, FileSystem, Option } from "effect" -import { fileURLToPath } from "url" -import { FSUtil } from "./fs-util" - -const SAMPLE_BYTES = 8192 - -export const resolve = Effect.fn("Mime.resolve")(function* (uri: string) { - const data = dataSample(uri) - if (data) return detect(data) - - const target = yield* Effect.try({ - try: () => localPath(uri), - catch: () => new Error("Invalid file URI"), - }).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!target) return "application/octet-stream" - - const fs = yield* FSUtil.Service - const local = yield* Effect.scoped( - Effect.gen(function* () { - const info = yield* fs.stat(target) - if (info.type === "Directory") return { type: "directory" as const } - if (info.type !== "File") return - const file = yield* fs.open(target) - return { - type: "file" as const, - sample: Option.getOrElse(yield* file.readAlloc(FileSystem.Size(SAMPLE_BYTES)), () => new Uint8Array()), - } - }), - ).pipe(Effect.catch(() => Effect.succeed(undefined))) - - if (local?.type === "directory") return "application/x-directory" - if (local?.type === "file") return detect(local.sample) - return "application/octet-stream" -}) - -function detect(bytes: Uint8Array) { +export function detect(bytes: Uint8Array) { if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" @@ -52,25 +17,6 @@ function detect(bytes: Uint8Array) { return isText(bytes) ? "text/plain" : "application/octet-stream" } -function dataSample(uri: string) { - if (!uri.startsWith("data:")) return - const comma = uri.indexOf(",") - if (comma === -1) return new Uint8Array() - const metadata = uri.slice(5, comma) - const payload = uri.slice(comma + 1) - if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) { - return Buffer.from(payload.slice(0, Math.ceil((SAMPLE_BYTES * 4) / 3) + 4), "base64").subarray(0, SAMPLE_BYTES) - } - return new TextEncoder().encode(payload.slice(0, SAMPLE_BYTES)) -} - -function localPath(uri: string) { - if (!URL.canParse(uri)) return - const url = new URL(uri) - if (url.protocol !== "file:") return - return fileURLToPath(url) -} - function startsWith(bytes: Uint8Array, prefix: number[]) { return prefix.every((value, index) => bytes[index] === value) } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 6f05f7b1f2d..b26b072dcf9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -9,7 +9,7 @@ import { WorkspaceV2 } from "./workspace" import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" -import { Prompt } from "./session/prompt" +import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { EventV2 } from "./event" import { Database } from "./database/database" @@ -121,6 +121,10 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.AttachmentError", { + uri: Schema.String, + message: Schema.String, +}) {} export class BusyError extends Schema.TaggedErrorClass()("Session.BusyError", { sessionID: SessionSchema.ID, }) {} @@ -135,6 +139,7 @@ export type Error = | MessageDecodeError | OperationUnavailableError | PromptConflictError + | AttachmentError | BusyError | SkillNotFoundError | CommandV2.NotFoundError @@ -189,7 +194,7 @@ export interface Interface { prompt: PromptInput.Prompt delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -203,7 +208,7 @@ export interface Interface { resume?: boolean }) => Effect.Effect< SessionInput.Admitted, - NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError + NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError > readonly shell: (input: { id?: EventV2.ID @@ -721,40 +726,98 @@ const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: Pro const files = input.files ? yield* Effect.forEach( input.files, - (file) => - Effect.gen(function* () { - const mime = yield* Mime.resolve(file.uri) - const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined - return { ...file, mime, ...(content === undefined ? {} : { content }) } - }), + (file) => materializeAttachment(fs, file), { concurrency: 8 }, ) : undefined return Prompt.make({ text: input.text, agents: input.agents, files }) }) -function readTextAttachment(fs: FSUtil.Interface, uri: string) { - if (uri.startsWith("data:")) return Effect.succeed(undefined) - return Effect.try({ +const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 + +const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* ( + fs: FSUtil.Interface, + input: PromptInput.FileAttachment, +) { + const resolved = input.uri.startsWith("data:") + ? { + bytes: yield* decodeDataURL(input.uri), + source: { type: "inline" as const }, + start: undefined, + end: undefined, + name: undefined, + } + : yield* readFileAttachment(fs, input.uri) + if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES) + return yield* new AttachmentError({ + uri: input.uri, + message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`, + }) + + const mime = Mime.detect(resolved.bytes) + const content = + mime === "text/plain" && resolved.start !== undefined + ? Buffer.from( + Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"), + ) + : resolved.bytes + return FileAttachment.create({ + data: Base64.make(Buffer.from(content).toString("base64")), + mime, + source: resolved.source, + name: input.name ?? resolved.name, + description: input.description, + mention: input.mention, + }) +}) + +const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) { + const url = yield* Effect.try({ try: () => new URL(uri), - catch: () => new Error("Invalid attachment URI"), - }).pipe( - Effect.flatMap((url) => { - if (url.protocol !== "file:") return Effect.succeed(undefined) - const start = positiveInt(url.searchParams.get("start")) - const end = positiveInt(url.searchParams.get("end")) + catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }), + }) + if (url.protocol !== "file:") + return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` }) + const start = positiveInt(url.searchParams.get("start")) + const end = positiveInt(url.searchParams.get("end")) + const target = yield* Effect.try({ + try: () => { url.search = "" url.hash = "" - return Effect.try({ - try: () => fileURLToPath(url), - catch: () => new Error("Invalid file URI"), - }).pipe( - Effect.flatMap((target) => fs.readFileString(target)), - Effect.map((content) => (start === undefined ? content : content.split("\n").slice(start - 1, end).join("\n"))), - ) - }), - Effect.catch(() => Effect.succeed(undefined)), + return fileURLToPath(url) + }, + catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }), + }) + const info = yield* fs.stat(target).pipe( + Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), ) + if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` }) + if (Number(info.size) > MAX_ATTACHMENT_BYTES) + return yield* new AttachmentError({ + uri, + message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`, + }) + const bytes = yield* fs.readFile(target).pipe( + Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), + ) + return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) } +}) + +function decodeDataURL(uri: string) { + return Effect.try({ + try: () => { + const comma = uri.indexOf(",") + if (comma === -1) throw new Error("Invalid data URL") + const metadata = uri.slice(5, comma) + const payload = uri.slice(comma + 1) + if (!metadata.split(";").some((part) => part.toLowerCase() === "base64")) + return Buffer.from(decodeURIComponent(payload)) + const bytes = Buffer.from(payload, "base64") + if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64") + return bytes + }, + catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }), + }) } function positiveInt(value: string | null) { diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 9ad9ddc81a0..4c5bbb4a6e3 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -106,7 +106,11 @@ 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}]`) ?? [] + const files = + message.files?.map( + (file) => + `[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`, + ) ?? [] return [`[User]: ${message.text}`, ...files].join("\n") } if (message.type === "assistant") { diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 1d0dfc71c3b..72622d32ef7 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -7,7 +7,7 @@ import type { Database } from "../database/database" import type { EventV2 } from "../event" import { SessionEvent } from "./event" import { SessionMessage } from "./message" -import { Prompt } from "./prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionSchema } from "./schema" import { SessionInputTable, SessionMessageTable } from "./sql" diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts deleted file mode 100644 index b079349c566..00000000000 --- a/packages/core/src/session/prompt.ts +++ /dev/null @@ -1 +0,0 @@ -export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index c9bdcbd2e19..d2eaee413b6 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -9,12 +9,14 @@ import { } from "@opencode-ai/llm" import { Option, Schema } from "effect" import { SessionMessage } from "../message" -import type { FileAttachment } from "../prompt" +import type { FileAttachment } from "@opencode-ai/schema/prompt" + +const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) const media = (file: FileAttachment): ContentPart => ({ type: "media", mediaType: file.mime, - data: file.uri, + data: file.data, filename: file.name, metadata: file.description === undefined ? undefined : { description: file.description }, }) @@ -23,37 +25,22 @@ const textAttachment = (file: FileAttachment) => Message.make({ role: "user", content: [ - `Attached file: ${file.name ?? file.uri}`, - `Source: ${file.uri}`, + `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, file.description === undefined ? undefined : `Description: ${file.description}`, "", - file.content ?? readTextData(file.uri) ?? "[Attachment content unavailable]", + Buffer.from(file.data, "base64").toString("utf8"), ] .filter((line): line is string => line !== undefined) .join("\n"), metadata: { attachment: { - uri: file.uri, + source: file.source, name: file.name, description: file.description, }, }, }) -function readTextData(uri: string) { - if (!uri.startsWith("data:")) return - const comma = uri.indexOf(",") - if (comma === -1) return - const metadata = uri.slice(5, comma) - const payload = uri.slice(comma + 1) - if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) return Buffer.from(payload, "base64").toString("utf8") - try { - return decodeURIComponent(payload) - } catch { - return - } -} - const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const toolInput = (tool: SessionMessage.AssistantTool) => @@ -160,7 +147,10 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] Message.make({ id: message.id, role: "user", - content: [{ type: "text", text: message.text }, ...files.filter((file) => file.mime !== "text/plain").map(media)], + content: [ + { type: "text", text: message.text }, + ...files.filter((file) => imageMimes.has(file.mime)).map(media), + ], metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index ceef6fe040b..3e6f79863c2 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" -import type { Prompt } from "./prompt" +import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" import type { Snapshot } from "../snapshot" import { PermissionV1 } from "../v1/permission" diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 98551e9f225..df63072fb9a 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -16,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1b07937ebdf..fc3b4d127d4 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -16,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -192,7 +192,7 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location, title: "Parent" }) const admitted = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "First" }), + prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -224,9 +224,9 @@ describe("SessionV2.create", () => { promotedSeq: 2, }) - yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false }) + yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false }) + yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) yield* SessionInput.promoteSteers(db, events, forked.id) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -249,13 +249,13 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location }) const first = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "First" }), + prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) const second = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "Second" }), + prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -373,7 +373,7 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service 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* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) yield* SessionInput.promoteSteers(db, events, created.id) expect( @@ -393,7 +393,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: Prompt.make({ text: "Replay lifecycle" }), + prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }), resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index da2e494731e..77dd272b120 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 163d831a5e9..0b41dd37353 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { mkdtemp, rm } from "fs/promises" +import { tmpdir } from "os" import path from "path" import { pathToFileURL } from "url" import { eq } from "drizzle-orm" @@ -15,7 +17,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -171,7 +173,7 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -195,7 +197,7 @@ describe("SessionV2.prompt", () => { const boundary = yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "boundary" }), + prompt: PromptInput.Prompt.make({ text: "boundary" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, sessionID) @@ -207,7 +209,7 @@ describe("SessionV2.prompt", () => { }) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "after revert" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "after revert" }), resume: false }) expect((yield* session.get(sessionID)).revert).toBeUndefined() expect( @@ -230,17 +232,25 @@ describe("SessionV2.prompt", () => { sessionID, prompt: { text: "Inspect this image", - files: [{ uri, name: "image.png" }], + files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], }, resume: false, }) - expect(message.prompt.files).toEqual([{ uri, name: "image.png", mime: "image/png" }]) + expect(message.prompt.files).toEqual([ + { + data: uri.slice(uri.indexOf(",") + 1), + mime: "image/png", + source: { type: "inline" }, + name: "image.png", + mention: { start: 8, end: 17, text: "[Image 1]" }, + }, + ]) expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) }), ) - it.effect("classifies source files and directories from local content", () => + it.effect("materializes selected source file content", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -253,27 +263,78 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, prompt: { - text: "Inspect these", - files: [ - { uri: sourceUri.href, name: "main.ts" }, - { uri: pathToFileURL(directory).href, name: "source" }, - ], + text: "Inspect this", + files: [{ uri: sourceUri.href, name: "main.ts" }], }, resume: false, }) expect(message.prompt.files).toEqual([ { - uri: sourceUri.href, - name: "main.ts", + data: Buffer.from('import { describe, expect } from "bun:test"').toString("base64"), mime: "text/plain", - content: 'import { describe, expect } from "bun:test"', + source: { type: "uri", uri: sourceUri.href }, + name: "main.ts", }, - { uri: pathToFileURL(directory).href, name: "source", mime: "application/x-directory" }, ]) }), ) + it.effect("rejects directories as file attachments", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const uri = pathToFileURL(import.meta.dir).href + + const error = yield* session + .prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, + resume: false, + }) + .pipe(Effect.flip) + + expect(error).toMatchObject({ + _tag: "Session.AttachmentError", + uri, + message: `Attachment is not a file: ${uri}`, + }) + }), + ) + + it.effect("materializes local image content before admission", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const directory = yield* Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(tmpdir(), "opencode-session-prompt-"))), + (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), + ) + const source = path.join(directory, "image.png") + const bytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", + ) + yield* Effect.promise(() => Bun.write(source, bytes)) + + const message = yield* session.prompt({ + sessionID, + prompt: { text: "Inspect this image", files: [{ uri: pathToFileURL(source).href }] }, + resume: false, + }) + + expect(message.prompt.files).toEqual([ + { + data: bytes.toString("base64"), + mime: "image/png", + source: { type: "uri", uri: pathToFileURL(source).href }, + name: "image.png", + }, + ]) + expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + }), + ) + it.effect("sniffs data URL content instead of trusting its declared MIME", () => Effect.gen(function* () { yield* setup @@ -286,7 +347,36 @@ describe("SessionV2.prompt", () => { resume: false, }) - expect(message.prompt.files).toEqual([{ uri, name: "main.ts", mime: "text/plain" }]) + expect(message.prompt.files).toEqual([ + { + data: Buffer.from("export const value = 1\n").toString("base64"), + mime: "text/plain", + source: { type: "inline" }, + name: "main.ts", + }, + ]) + }), + ) + + it.effect("rejects malformed base64 data URLs", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const uri = "data:image/png;base64,not-base64" + + const error = yield* session + .prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "image.png" }] }, + resume: false, + }) + .pipe(Effect.flip) + + expect(error).toMatchObject({ + _tag: "Session.AttachmentError", + uri, + message: "Invalid attachment data URL", + }) }), ) @@ -303,8 +393,8 @@ describe("SessionV2.prompt", () => { const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID) const streamed = Array.from(yield* Fiber.join(fiber)) @@ -328,7 +418,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -347,7 +437,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -365,7 +455,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -385,7 +475,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: Prompt.make({ text: "Recover committed prompt" }), + prompt: PromptInput.Prompt.make({ text: "Recover committed prompt" }), resume: false, } const first = yield* session.prompt(input) @@ -406,13 +496,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: Prompt.make({ text: "Delete the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Delete the failing tests" }), resume: false, }) .pipe(Effect.flip) @@ -431,14 +521,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), delivery: "queue", resume: false, }) @@ -455,7 +545,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: Prompt.make({ text: "Fix the failing tests" }), + prompt: PromptInput.Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -474,7 +564,7 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false }) yield* Effect.all( [SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)], @@ -499,7 +589,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: Prompt.make({ text: "Replay pending" }), + prompt: PromptInput.Prompt.make({ text: "Replay pending" }), resume: false, }) const recorded = yield* db @@ -552,7 +642,7 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = Prompt.make({ text: "Fix the failing tests" }) + const prompt = PromptInput.Prompt.make({ text: "Fix the failing tests" }) yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* session @@ -586,7 +676,7 @@ describe("SessionV2.prompt", () => { .pipe(Effect.orDie) const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) + .prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) @@ -601,7 +691,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run by default" }) }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) @@ -617,7 +707,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Run explicitly" }), + prompt: PromptInput.Prompt.make({ text: "Run explicitly" }), resume: true, }) @@ -633,7 +723,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 8fdc53631eb..469d5e0360a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" -import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt" +import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" import { Shell } from "@opencode-ai/schema/shell" @@ -49,7 +49,12 @@ describe("toLLMMessages", () => { }) test("maps every top-level V2 Session message type", () => { - const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const file = FileAttachment.make({ + data: Base64.make("aGVsbG8="), + mime: "image/png", + source: { type: "inline" }, + name: "hello.png", + }) const messages = toLLMMessages( [ SessionMessage.AgentSelected.make({ @@ -122,7 +127,7 @@ describe("toLLMMessages", () => { role: "user", content: [ { type: "text", text: "Inspect this image" }, - { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" }, + { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" }, ], metadata: { agents: [{ name: "build" }] }, }), @@ -151,9 +156,9 @@ Recent work test("lowers text attachments as separate user messages", () => { const file = FileAttachment.make({ - uri: "file:///project/main.ts", + data: Base64.make(Buffer.from("export const value = 1").toString("base64")), mime: "text/plain", - content: "export const value = 1", + source: { type: "uri", uri: "file:///project/main.ts" }, name: "main.ts", }) const messages = toLLMMessages( @@ -175,10 +180,10 @@ Recent work content: [ { type: "text", - text: "Attached file: main.ts\nSource: file:///project/main.ts\n\nexport const value = 1", + text: "Attached file: main.ts\n\nexport const value = 1", }, ], - metadata: { attachment: { uri: "file:///project/main.ts", name: "main.ts" } }, + metadata: { attachment: { source: file.source, name: "main.ts" } }, }) expect(messages[1]).toMatchObject({ id: id("user-text-file"), @@ -187,15 +192,21 @@ Recent work }) }) - test("derives text attachment content from durable data URLs", () => { - const uri = `data:text/plain;base64,${Buffer.from("inline content").toString("base64")}` + test("decodes inline text attachment content", () => { const messages = toLLMMessages( [ SessionMessage.User.make({ id: id("user-data-file"), type: "user", text: "Review this file", - files: [FileAttachment.make({ uri, mime: "text/plain", name: "inline.txt" })], + files: [ + FileAttachment.make({ + data: Base64.make(Buffer.from("inline content").toString("base64")), + mime: "text/plain", + source: { type: "inline" }, + name: "inline.txt", + }), + ], time: { created }, }), ], @@ -205,11 +216,40 @@ Recent work expect(messages[0]?.content).toEqual([ { type: "text", - text: `Attached file: inline.txt\nSource: ${uri}\n\ninline content`, + text: "Attached file: inline.txt\n\ninline content", }, ]) }) + test("uses materialized image data as provider media and drops unsupported attachments", () => { + const data = Base64.make("AAECAw==") + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-local-image"), + type: "user", + text: "Inspect this image", + files: [ + FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }), + FileAttachment.make({ + data: Base64.make("JVBERg=="), + mime: "application/pdf", + source: { type: "inline" }, + name: "document.pdf", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { type: "media", mediaType: "image/png", data, filename: "image.png" }, + ]) + }) + test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 15a1b35fd46..aa2484d34f8 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -172,7 +172,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Say hello in one short sentence." }), + prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }), resume: false, }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 99ccf338d28..3c66bbb5202 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -30,7 +30,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -375,7 +375,7 @@ const setupOverflowRecovery = Effect.gen(function* () { response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Earlier question ".repeat(700) }), + prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }), resume: false, }) yield* session.resume(sessionID) @@ -516,7 +516,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -547,7 +547,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -569,7 +569,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -613,7 +613,7 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use application context" }), resume: false }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -661,7 +661,7 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) }) + const message = yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run automatically" }) }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -675,8 +675,8 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 responses = undefined @@ -703,7 +703,7 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -721,7 +721,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) }) + yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -735,7 +735,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) @@ -752,7 +752,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -766,7 +766,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -795,7 +795,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) yield* db @@ -804,7 +804,7 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 yield* session.resume(sessionID) @@ -828,13 +828,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -863,7 +863,7 @@ describe("SessionRunnerLLM", () => { yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents @@ -888,7 +888,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents @@ -912,7 +912,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-build", ["Done"]).completeEvents @@ -938,7 +938,7 @@ describe("SessionRunnerLLM", () => { editor.default(AgentV2.ID.make("reviewer")) }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents @@ -960,7 +960,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents @@ -988,7 +988,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents @@ -1005,7 +1005,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1015,7 +1015,7 @@ describe("SessionRunnerLLM", () => { sessionID, agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1044,7 +1044,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1072,7 +1072,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1088,13 +1088,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1111,7 +1111,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1125,7 +1125,7 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1146,7 +1146,7 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) @@ -1162,20 +1162,20 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1195,7 +1195,7 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1205,7 +1205,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1215,11 +1215,11 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1235,7 +1235,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1252,7 +1252,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1260,7 +1260,7 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1272,7 +1272,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Earlier question ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1285,7 +1285,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1311,7 +1311,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1339,7 +1339,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1369,7 +1369,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents, overflow(), ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1397,7 +1397,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1415,7 +1415,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1438,7 +1438,7 @@ describe("SessionRunnerLLM", () => { const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1458,13 +1458,13 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { @@ -1478,7 +1478,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) // The rebaseline proceeds while the source is unobservable, restating the model's belief. @@ -1491,7 +1491,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false }) requests.length = 0 responses = undefined @@ -1589,7 +1589,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 authorizations.length = 0 @@ -1648,7 +1648,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 responses = [ @@ -1690,7 +1690,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false }) requests.length = 0 response = [ @@ -1728,7 +1728,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1747,7 +1747,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false }) requests.length = 0 response = [ @@ -1772,7 +1772,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1802,7 +1802,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1863,7 +1863,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1951,7 +1951,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false }) requests.length = 0 responses = undefined @@ -1990,7 +1990,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2010,7 +2010,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2033,7 +2033,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2061,7 +2061,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Wait until continuation ends" }), + prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2081,7 +2081,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2099,7 +2099,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Run after interrupt" }), + prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }), delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2124,7 +2124,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2142,7 +2142,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Steer after interrupt" }), + prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }), }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2166,7 +2166,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2191,8 +2191,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2209,10 +2209,10 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Queue for later" }), + prompt: PromptInput.Prompt.make({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2243,7 +2243,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2274,13 +2274,13 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2308,7 +2308,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2328,8 +2328,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2348,7 +2348,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = undefined @@ -2359,7 +2359,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2378,7 +2378,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), resume: false }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2436,7 +2436,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recover interrupted hosted tool" }), + prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) @@ -2492,7 +2492,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recover interrupted tool input" }), + prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) @@ -2528,7 +2528,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Wait in queue" }), + prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), delivery: "queue", resume: false, }) @@ -2550,7 +2550,7 @@ describe("SessionRunnerLLM", () => { const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2580,7 +2580,7 @@ describe("SessionRunnerLLM", () => { ) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Run committed promotion" }), + prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }), resume: false, }) @@ -2597,8 +2597,8 @@ describe("SessionRunnerLLM", () => { yield* setup yield* insertSession(otherSessionID) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) + yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false }) requests.length = 0 responses = undefined @@ -2635,12 +2635,12 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID: longSessionID, - prompt: Prompt.make({ text: "Run long session" }), + prompt: PromptInput.Prompt.make({ text: "Run long session" }), resume: false, }) yield* session.prompt({ sessionID: otherLongSessionID, - prompt: Prompt.make({ text: "Run other long session" }), + prompt: PromptInput.Prompt.make({ text: "Run other long session" }), resume: false, }) @@ -2659,7 +2659,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), resume: false }) requests.length = 0 responses = undefined @@ -2690,7 +2690,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false }) requests.length = 0 responses = [ @@ -2736,7 +2736,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false }) requests.length = 0 responses = [ @@ -2784,7 +2784,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call storefail" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call storefail" }), resume: false }) requests.length = 0 responses = [ @@ -2836,7 +2836,7 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new QuestionTool.CancelledError()), }), }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Ask then stop" }), resume: false }) requests.length = 0 responses = [ @@ -2875,7 +2875,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), resume: false }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -2909,7 +2909,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -2959,7 +2959,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), resume: false }) requests.length = 0 response = [] streamGate = yield* Deferred.make() @@ -2987,7 +2987,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() @@ -3037,7 +3037,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), resume: false }) requests.length = 0 executions.length = 0 @@ -3085,7 +3085,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start work" }), resume: false }) requests.length = 0 executions.length = 0 @@ -3113,7 +3113,7 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) streamGate = undefined @@ -3131,7 +3131,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail durably" }), resume: false }) requests.length = 0 responses = undefined @@ -3153,7 +3153,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail before step" }), resume: false }) requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] @@ -3172,7 +3172,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail after output" }), resume: false }) requests.length = 0 response = [ @@ -3201,7 +3201,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), resume: false }) const failure = providerUnavailable() responseStream = Stream.fail(failure) @@ -3220,7 +3220,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Do not continue failed provider" }), + prompt: PromptInput.Prompt.make({ text: "Do not continue failed provider" }), resume: false, }) @@ -3243,7 +3243,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) requests.length = 0 response = [ @@ -3274,7 +3274,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -3301,7 +3301,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }), + prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }), resume: false, }) const failure = providerUnavailable() @@ -3336,7 +3336,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) responses = undefined streamGate = undefined @@ -3400,7 +3400,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), resume: false }) responses = undefined streamGate = undefined diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 59abb8fabc9..f962672494c 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -9,7 +9,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 7d227aac19b..d96f6e5bd52 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -6,11 +6,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Agent } from "@opencode-ai/schema/agent" import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" -import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" import { Provider } from "@opencode-ai/schema/provider" import { Project } from "@opencode-ai/schema/project" import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { PermissionV1 } from "@opencode-ai/schema/permission-v1" +import { Prompt } from "@opencode-ai/schema/prompt" import { Session } from "@opencode-ai/schema/session" import { SessionInput } from "@opencode-ai/schema/session-input" import { SessionMessage } from "@opencode-ai/schema/session-message" @@ -47,7 +47,6 @@ test("Core reuses the canonical shared schemas", async () => { coreSessionInput, coreSessionMessage, coreSessionTodo, - corePrompt, coreSkill, coreV2Schema, coreSchema, @@ -69,7 +68,6 @@ test("Core reuses the canonical shared schemas", async () => { import("@opencode-ai/core/session/input"), import("@opencode-ai/core/session/message"), import("@opencode-ai/core/session/todo"), - import("@opencode-ai/core/session/prompt"), import("@opencode-ai/core/skill"), import("@opencode-ai/core/v2-schema"), import("@opencode-ai/core/schema"), @@ -164,10 +162,6 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.Message, SessionMessage.Message], [coreSessionTodo.Info, SessionTodo.Info], [coreSessionTodo.Event, SessionTodo.Event], - [corePrompt.Source, Source], - [corePrompt.FileAttachment, FileAttachment], - [corePrompt.AgentAttachment, AgentAttachment], - [corePrompt.Prompt, Prompt], [coreSkill.DirectorySource, Skill.DirectorySource], [coreSkill.UrlSource, Skill.UrlSource], [coreSkill.EmbeddedSource, Skill.EmbeddedSource], diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 4e2cc5dd9bb..8819d7d9e4e 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -264,7 +264,7 @@ export const makeSessionGroup = (sessionLo resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError], + error: [ConflictError, InvalidRequestError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) .annotateMerge( @@ -290,7 +290,13 @@ export const makeSessionGroup = (sessionLo resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], + error: [ + ConflictError, + InvalidRequestError, + SessionNotFoundError, + CommandNotFoundError, + CommandEvaluationError, + ], }) .middleware(sessionLocationMiddleware) .annotateMerge( diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1454fab1cf4..831f97b0664 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -27,6 +27,6 @@ export { Pty } from "./pty.js" export { PtyTicket } from "./pty-ticket.js" export { Question } from "./question.js" export { Workspace } from "./workspace.js" -export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js" +export { Prompt, PromptMention, FileSource, FileAttachment, AgentAttachment } from "./prompt.js" export { PromptInput } from "./prompt-input.js" export * from "./schema.js" diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts index d573f385550..a52aa23c83c 100644 --- a/packages/schema/src/prompt-input.ts +++ b/packages/schema/src/prompt-input.ts @@ -1,7 +1,7 @@ export * as PromptInput from "./prompt-input.js" import { Schema } from "effect" -import { AgentAttachment, Source } from "./prompt.js" +import { AgentAttachment, PromptMention } from "./prompt.js" import { optional, statics } from "./schema.js" export interface FileAttachment extends Schema.Schema.Type {} @@ -9,7 +9,7 @@ export const FileAttachment = Schema.Struct({ uri: Schema.String, name: Schema.String.pipe(optional), description: Schema.String.pipe(optional), - source: Source.pipe(optional), + mention: PromptMention.pipe(optional), }) .annotate({ identifier: "PromptInput.FileAttachment" }) .pipe( diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index 2438df2e985..3b3a5ac2712 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -2,33 +2,46 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { statics } from "./schema.js" -export interface Source extends Schema.Schema.Type {} -export const Source = Schema.Struct({ +export interface PromptMention extends Schema.Schema.Type {} +export const PromptMention = Schema.Struct({ start: Schema.Finite, end: Schema.Finite, text: Schema.String, -}).annotate({ identifier: "Prompt.Source" }) +}).annotate({ identifier: "Prompt.Mention" }) + +export const FileSource = Schema.Union([ + Schema.Struct({ type: Schema.Literal("inline") }), + Schema.Struct({ type: Schema.Literal("uri"), uri: Schema.String }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Prompt.FileSource" }) +export type FileSource = typeof FileSource.Type + +export const Base64 = Schema.String.check( + Schema.isPattern(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), +).annotate({ identifier: "Prompt.Base64" }) +export type Base64 = typeof Base64.Type export interface FileAttachment extends Schema.Schema.Type {} export const FileAttachment = Schema.Struct({ - uri: Schema.String, + data: Base64, mime: Schema.String, - content: Schema.String.pipe(optional), + source: FileSource, name: Schema.String.pipe(optional), description: Schema.String.pipe(optional), - source: Source.pipe(optional), + mention: PromptMention.pipe(optional), }) .annotate({ identifier: "Prompt.FileAttachment" }) .pipe( statics((schema) => ({ create: (input: FileAttachment) => schema.make({ - uri: input.uri, + data: input.data, mime: input.mime, - content: input.content, + source: input.source, name: input.name, description: input.description, - source: input.source, + mention: input.mention, }), })), ) @@ -36,7 +49,7 @@ export const FileAttachment = Schema.Struct({ export interface AgentAttachment extends Schema.Schema.Type {} export const AgentAttachment = Schema.Struct({ name: Schema.String, - source: Source.pipe(optional), + mention: PromptMention.pipe(optional), }).annotate({ identifier: "Prompt.AgentAttachment" }) export interface Prompt extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fc7bedea1f0..f1215bf713a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3255,24 +3255,35 @@ export type LocationRef = { workspaceID?: string } -export type PromptSource = { +export type PromptBase64 = string + +export type PromptFileSource = + | { + type: "inline" + } + | { + type: "uri" + uri: string + } + +export type PromptMention = { start: number end: number text: string } export type PromptFileAttachment = { - uri: string + data: PromptBase64 mime: string - content?: string + source: PromptFileSource name?: string description?: string - source?: PromptSource + mention?: PromptMention } export type PromptAgentAttachment = { name: string - source?: PromptSource + mention?: PromptMention } export type SessionErrorUnknown = { @@ -4279,7 +4290,7 @@ export type PromptInputFileAttachment = { uri: string name?: string description?: string - source?: PromptSource + mention?: PromptMention } export type SessionInputAdmitted = { @@ -7877,7 +7888,7 @@ export type MessageNotFoundErrorV2 = { message: string } -export type PromptSource2 = { +export type PromptMention2 = { start: number end: number text: string @@ -7887,12 +7898,12 @@ export type PromptInputFileAttachment2 = { uri: string name?: string description?: string - source?: PromptSource2 + mention?: PromptMention2 } export type PromptAgentAttachment2 = { name: string - source?: PromptSource2 + mention?: PromptMention2 } export type PromptInputV2 = { @@ -7901,13 +7912,24 @@ export type PromptInputV2 = { agents?: Array } +export type PromptBase642 = string + +export type PromptFileSource2 = + | { + type: "inline" + } + | { + type: "uri" + uri: string + } + export type PromptFileAttachment2 = { - uri: string + data: PromptBase642 mime: string - content?: string + source: PromptFileSource2 name?: string description?: string - source?: PromptSource2 + mention?: PromptMention2 } export type PromptV2 = { @@ -15965,7 +15987,7 @@ export type V2SessionPromptErrors = { /** * InvalidRequestError */ - 400: InvalidRequestErrorV2 + 400: InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ @@ -16016,7 +16038,7 @@ export type V2SessionCommandErrors = { /** * InvalidRequestError */ - 400: InvalidRequestErrorV2 + 400: InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2bb45d52ed1..aa79b8bd51e 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -9,6 +9,7 @@ import { CommandEvaluationError, CommandNotFoundError, InvalidCursorError, + InvalidRequestError, MessageNotFoundError, ServiceUnavailableError, SessionBusyError, @@ -213,6 +214,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.AttachmentError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })), + ), ), } }), @@ -267,6 +271,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.AttachmentError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), + ), ), } }), diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 50bf0a64a26..65bff40838d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -210,17 +210,17 @@ export function Autocomplete(props: { const existingIndex = files.findIndex((file) => file.uri === part.value.uri) if (existingIndex !== -1) { const existing = files[existingIndex] - if (existing?.source) { - existing.source.start = extmarkStart - existing.source.end = extmarkEnd - existing.source.text = virtualText + if (existing?.mention) { + existing.mention.start = extmarkStart + existing.mention.end = extmarkEnd + existing.mention.text = virtualText } return } - if (part.value.source) { - part.value.source.start = extmarkStart - part.value.source.end = extmarkEnd - part.value.source.text = virtualText + if (part.value.mention) { + part.value.mention.start = extmarkStart + part.value.mention.end = extmarkEnd + part.value.mention.text = virtualText } const index = files.length files.push(part.value) @@ -229,10 +229,10 @@ export function Autocomplete(props: { } const agents = (draft.agents ??= []) - if (part.value.source) { - part.value.source.start = extmarkStart - part.value.source.end = extmarkEnd - part.value.source.text = virtualText + if (part.value.mention) { + part.value.mention.start = extmarkStart + part.value.mention.end = extmarkEnd + part.value.mention.text = virtualText } const index = agents.length agents.push(part.value) @@ -268,7 +268,7 @@ export function Autocomplete(props: { value: { uri: urlObj.href, name: filename, - source: { start: 0, end: 0, text: "" }, + mention: { start: 0, end: 0, text: "" }, }, }, } @@ -376,7 +376,7 @@ export function Autocomplete(props: { uri: res.uri, name: res.name, description: res.description, - source: { start: 0, end: 0, text: "" }, + mention: { start: 0, end: 0, text: "" }, }, }) }, @@ -397,7 +397,7 @@ export function Autocomplete(props: { type: "agent", value: { name: agent.id, - source: { start: 0, end: 0, text: "" }, + mention: { start: 0, end: 0, text: "" }, }, }) }, @@ -419,7 +419,7 @@ export function Autocomplete(props: { value: { uri: pathToFileURL(reference.path).href, name: reference.name, - source: { start: 0, end: 0, text: "" }, + mention: { start: 0, end: 0, text: "" }, }, }) }, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c7276206a90..cccb9e6bf01 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -483,17 +483,17 @@ export function Prompt(props: PromptProps) { // Update attachment positions and drop virtual text deleted in the editor. // this handles a case where the user edits the text in the editor // such that the virtual text moves around or is deleted - const moveSource = (part: Part) => { - if (!part.source?.text) return part - const start = normalized.indexOf(part.source.text) + const moveMention = (part: Part) => { + if (!part.mention?.text) return part + const start = normalized.indexOf(part.mention.text) if (start === -1) return - return { ...part, source: { ...part.source, start, end: start + part.source.text.length } } + return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } } } setStore("prompt", { text: normalized, - files: store.prompt.files?.map(moveSource).filter((part) => part !== undefined), - agents: store.prompt.agents?.map(moveSource).filter((part) => part !== undefined), + files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined), + agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined), pasted: [], }) restoreExtmarksFromPrompt(store.prompt) @@ -647,16 +647,28 @@ export function Prompt(props: PromptProps) { setStore("extmarkToPart", new Map()) const parts = [ - ...(prompt.files ?? []).map((part, index) => ({ part, ref: { type: "file" as const, index }, styleId: fileStyleId })), - ...(prompt.agents ?? []).map((part, index) => ({ part, ref: { type: "agent" as const, index }, styleId: agentStyleId })), - ...prompt.pasted.map((part, index) => ({ part, ref: { type: "pasted" as const, index }, styleId: pasteStyleId })), + ...(prompt.files ?? []).map((part, index) => ({ + mention: part.mention, + ref: { type: "file" as const, index }, + styleId: fileStyleId, + })), + ...(prompt.agents ?? []).map((part, index) => ({ + mention: part.mention, + ref: { type: "agent" as const, index }, + styleId: agentStyleId, + })), + ...prompt.pasted.map((part, index) => ({ + mention: part.source, + ref: { type: "pasted" as const, index }, + styleId: pasteStyleId, + })), ] - parts.forEach(({ part, ref, styleId }) => { - if (part.source?.text) { + parts.forEach(({ mention, ref, styleId }) => { + if (mention?.text) { const extmarkId = input.extmarks.create({ - start: part.source.start, - end: part.source.end, + start: mention.start, + end: mention.end, virtual: true, styleId, typeId: promptPartTypeId, @@ -684,9 +696,9 @@ export function Prompt(props: PromptProps) { if (!ref) continue if (ref.type === "file") { const part = draft.prompt.files?.[ref.index] - if (!part?.source) continue - part.source.start = extmark.start - part.source.end = extmark.end + if (!part?.mention) continue + part.mention.start = extmark.start + part.mention.end = extmark.end const index = files.length files.push(part) newMap.set(extmark.id, { type: "file", index }) @@ -694,9 +706,9 @@ export function Prompt(props: PromptProps) { } if (ref.type === "agent") { const part = draft.prompt.agents?.[ref.index] - if (!part?.source) continue - part.source.start = extmark.start - part.source.end = extmark.end + if (!part?.mention) continue + part.mention.start = extmark.start + part.mention.end = extmark.end const index = agents.length agents.push(part) newMap.set(extmark.id, { type: "agent", index }) @@ -1248,7 +1260,7 @@ export function Prompt(props: PromptProps) { const part: NonNullable[number] = { uri: file.uri, name: file.filename, - source: { + mention: { start: extmarkStart, end: extmarkEnd, text: virtualText, diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx index d056ad5bae1..d0952a907e6 100644 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx @@ -59,7 +59,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess files.push({ uri: part.url, name: part.filename, - source: part.source?.text + mention: part.source?.text ? { start: part.source.text.start, end: part.source.text.end, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 5a52f11c873..b1d036cd68a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1428,7 +1428,7 @@ function UserMessage(props: { message: SessionMessageUser }) { {" "} - {file.name ?? file.uri}{" "} + {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} )