Prechádzať zdrojové kódy

feat(core): resolve prompt attachments

Dax Raad 1 mesiac pred
rodič
commit
4d100de194
29 zmenil súbory, kde vykonal 610 pridanie a 426 odobranie
  1. 2 2
      packages/client/src/promise/generated/client.ts
  2. 63 63
      packages/client/src/promise/generated/types.ts
  3. 0 2
      packages/client/test/contract-identity.test.ts
  4. 1 55
      packages/core/src/mime.ts
  5. 90 27
      packages/core/src/session.ts
  6. 5 1
      packages/core/src/session/compaction.ts
  7. 1 1
      packages/core/src/session/input.ts
  8. 0 1
      packages/core/src/session/prompt.ts
  9. 11 21
      packages/core/src/session/runner/to-llm-message.ts
  10. 1 1
      packages/core/src/session/sql.ts
  11. 1 1
      packages/core/test/session-compact.test.ts
  12. 8 8
      packages/core/test/session-create.test.ts
  13. 1 1
      packages/core/test/session-projector.test.ts
  14. 125 35
      packages/core/test/session-prompt.test.ts
  15. 51 11
      packages/core/test/session-runner-message.test.ts
  16. 2 2
      packages/core/test/session-runner-recorded.test.ts
  17. 119 119
      packages/core/test/session-runner.test.ts
  18. 1 1
      packages/core/test/session-title.test.ts
  19. 1 7
      packages/core/test/shared-schema.test.ts
  20. 8 2
      packages/protocol/src/groups/session.ts
  21. 1 1
      packages/schema/src/index.ts
  22. 2 2
      packages/schema/src/prompt-input.ts
  23. 23 10
      packages/schema/src/prompt.ts
  24. 36 14
      packages/sdk/js/src/v2/gen/types.gen.ts
  25. 7 0
      packages/server/src/handlers/session.ts
  26. 16 16
      packages/tui/src/component/prompt/autocomplete.tsx
  27. 32 20
      packages/tui/src/component/prompt/index.tsx
  28. 1 1
      packages/tui/src/routes/session/dialog-fork-from-timeline.tsx
  29. 1 1
      packages/tui/src/routes/session/index.tsx

+ 2 - 2
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,

+ 63 - 63
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"

+ 0 - 2
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))

+ 1 - 55
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)
 }

+ 90 - 27
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<PromptConflictE
   sessionID: SessionSchema.ID,
   messageID: SessionMessage.ID,
 }) {}
+export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()("Session.AttachmentError", {
+  uri: Schema.String,
+  message: Schema.String,
+}) {}
 export class BusyError extends Schema.TaggedErrorClass<BusyError>()("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<SessionInput.Admitted, NotFoundError | PromptConflictError>
+  }) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError | AttachmentError>
   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) {

+ 5 - 1
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") {

+ 1 - 1
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"
 

+ 0 - 1
packages/core/src/session/prompt.ts

@@ -1 +0,0 @@
-export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"

+ 11 - 21
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 } : {}),

+ 1 - 1
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"

+ 1 - 1
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"

+ 8 - 8
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)

+ 1 - 1
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"

+ 125 - 35
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([])

+ 51 - 11
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(
       [

+ 2 - 2
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,
       })
 

+ 119 - 119
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<void>()
-    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<void>()
       const summaryGate = yield* Deferred.make<void>()
       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<void>()
       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<void>()
       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<void>()
@@ -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<void>()
       toolExecutionsStarted = yield* Deferred.make<void>()
@@ -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

+ 1 - 1
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"

+ 1 - 7
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],

+ 8 - 2
packages/protocol/src/groups/session.ts

@@ -264,7 +264,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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 = <I extends HttpApiMiddleware.AnyId, S>(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(

+ 1 - 1
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"

+ 2 - 2
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<typeof FileAttachment> {}
@@ -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(

+ 23 - 10
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<typeof Source> {}
-export const Source = Schema.Struct({
+export interface PromptMention extends Schema.Schema.Type<typeof PromptMention> {}
+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<typeof FileAttachment> {}
 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<typeof AgentAttachment> {}
 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<typeof Prompt> {}

+ 36 - 14
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<PromptAgentAttachment2>
 }
 
+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
    */

+ 7 - 0
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" })),
+                ),
               ),
           }
         }),

+ 16 - 16
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: "" },
               },
             })
           },

+ 32 - 20
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 extends { source?: { start: number; end: number; text: string } }>(part: Part) => {
-            if (!part.source?.text) return part
-            const start = normalized.indexOf(part.source.text)
+          const moveMention = <Part extends { mention?: { start: number; end: number; text: string } }>(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<PromptInfo["files"]>[number] = {
       uri: file.uri,
       name: file.filename,
-      source: {
+      mention: {
         start: extmarkStart,
         end: extmarkEnd,
         text: virtualText,

+ 1 - 1
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,

+ 1 - 1
packages/tui/src/routes/session/index.tsx

@@ -1428,7 +1428,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
                       </span>
                       <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
                         {" "}
-                        {file.name ?? file.uri}{" "}
+                        {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
                       </span>
                     </text>
                   )