diff --git a/packages/cli/src/mini/session.shared.ts b/packages/cli/src/mini/session.shared.ts index e68df9e6c2..b6f1f6dd11 100644 --- a/packages/cli/src/mini/session.shared.ts +++ b/packages/cli/src/mini/session.shared.ts @@ -176,22 +176,22 @@ export async function resolveCurrentSession( parts: [ ...(message.files ?? []).map((file) => ({ type: "file" as const, - url: file.uri, + url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, mime: file.mime, filename: file.name, - source: file.source + source: file.mention ? { type: "file" as const, - path: file.name ?? file.uri, - text: { start: file.source.start, end: file.source.end, value: file.source.text }, + path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"), + text: { start: file.mention.start, end: file.mention.end, value: file.mention.text }, } : undefined, })), ...(message.agents ?? []).map((agent) => ({ type: "agent" as const, name: agent.name, - source: agent.source - ? { start: agent.source.start, end: agent.source.end, value: agent.source.text } + source: agent.mention + ? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text } : undefined, })), ], diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 8cde84695c..3e47320953 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -42,5 +42,6 @@ export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Skill } from "@opencode-ai/schema/skill" export { Prompt } from "@opencode-ai/schema/prompt" +export { PromptInput } from "@opencode-ai/schema/prompt-input" export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" export type OpenCodeClient = Effect.Success> diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 929bc2ecb6..e042859b91 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -477,7 +477,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, successStatus: 200, - declaredStatuses: [409, 404, 400, 401], + declaredStatuses: [409, 400, 404, 401], empty: false, }, requestOptions, @@ -499,7 +499,7 @@ export function make(options: ClientOptions) { resume: input["resume"], }, successStatus: 200, - declaredStatuses: [409, 404, 500, 400, 401], + declaredStatuses: [409, 400, 404, 500, 401], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0473ce4a5e..afe02c11c0 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -528,11 +528,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -546,11 +546,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -564,11 +564,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -582,11 +582,11 @@ export type SessionPromptInput = { readonly uri: string readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery?: "steer" | "queue" | null @@ -602,15 +602,16 @@ export type SessionPromptOutput = { readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: 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" @@ -631,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 @@ -650,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 @@ -669,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 @@ -688,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 @@ -707,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 @@ -726,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 @@ -745,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 @@ -764,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 @@ -783,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 @@ -802,15 +803,16 @@ export type SessionCommandOutput = { readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: 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" @@ -932,15 +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 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" } @@ -1035,11 +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 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 } @@ -1194,15 +1198,16 @@ export type SessionLogOutput = readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: 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" @@ -1626,15 +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 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" } @@ -1729,11 +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 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 } @@ -1826,15 +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 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" } @@ -1929,11 +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 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 } @@ -4476,15 +4485,16 @@ export type EventSubscribeOutput = readonly prompt: { readonly text: string readonly files?: ReadonlyArray<{ - readonly uri: string + readonly data: string readonly mime: string + readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } readonly name?: string readonly description?: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } + readonly mention?: { readonly start: number; readonly end: number; readonly text: string } }> } readonly delivery: "steer" | "queue" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index b2c5cb7c8a..90ac5d8cfc 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -7,7 +7,6 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" -import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt" import { Agent } from "@opencode-ai/schema/agent" import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" @@ -32,7 +31,6 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) - expect(CorePrompt).toBe(Prompt) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) diff --git a/packages/core/src/mime.ts b/packages/core/src/mime.ts new file mode 100644 index 0000000000..6ad8de5a60 --- /dev/null +++ b/packages/core/src/mime.ts @@ -0,0 +1,34 @@ +export * as Mime from "./mime.js" + +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" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) + return "image/webp" + if ( + startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) && + (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || + startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73])) + ) + return "image/avif" + return isText(bytes) ? "text/plain" : "application/octet-stream" +} + +function startsWith(bytes: Uint8Array, prefix: number[]) { + return prefix.every((value, index) => bytes[index] === value) +} + +function isText(bytes: Uint8Array) { + if (bytes.length === 0) return true + if (bytes.includes(0)) return false + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }) + } catch { + return false + } + const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) + return controls / bytes.length <= 0.3 +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 02359903e3..b26b072dcf 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -9,7 +9,7 @@ import { WorkspaceV2 } from "./workspace" import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" -import { Prompt } from "./session/prompt" +import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { EventV2 } from "./event" import { Database } from "./database/database" @@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" +import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" import { Job } from "./job" @@ -44,6 +45,7 @@ import { CommandV2 } from "./command" import { Shell } from "./shell" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" +import { fileURLToPath } from "url" export const RevertState = Revert.State export type RevertState = Revert.State @@ -119,6 +121,10 @@ export class PromptConflictError extends Schema.TaggedErrorClass()("Session.AttachmentError", { + uri: Schema.String, + message: Schema.String, +}) {} export class BusyError extends Schema.TaggedErrorClass()("Session.BusyError", { sessionID: SessionSchema.ID, }) {} @@ -133,6 +139,7 @@ export type Error = | MessageDecodeError | OperationUnavailableError | PromptConflictError + | AttachmentError | BusyError | SkillNotFoundError | CommandV2.NotFoundError @@ -187,7 +194,7 @@ export interface Interface { prompt: PromptInput.Prompt delivery?: SessionInput.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly command: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -201,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 @@ -251,6 +258,7 @@ const layer = Layer.effect( const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service + const fs = yield* FSUtil.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope const activeShells = new Set() @@ -456,7 +464,7 @@ const layer = Layer.effect( // continues from the reverted boundary rather than stale post-boundary history. if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = resolvePrompt(input.prompt) + const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs)) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" const expected = { sessionID: input.sessionID, messageID, prompt, delivery } @@ -713,19 +721,110 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } } -const resolvePrompt = (input: PromptInput.Prompt) => - Prompt.make({ - text: input.text, - agents: input.agents, - files: input.files?.map((file) => { - const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1] - const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri) - return { - ...file, - mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)), +const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { + const fs = yield* FSUtil.Service + const files = input.files + ? yield* Effect.forEach( + input.files, + (file) => materializeAttachment(fs, file), + { concurrency: 8 }, + ) + : undefined + return Prompt.make({ text: input.text, agents: input.agents, files }) +}) + +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 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 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) { + if (value === null) return + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined +} // Mirrors the shell tool's in-memory preview safety limit. const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 @@ -742,5 +841,6 @@ export const node = makeGlobalNode({ SessionStore.node, LocationServiceMap.node, SessionProjector.node, + FSUtil.node, ], }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index a30edbd434..375d6e5266 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -94,7 +94,11 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[ const serialize = (message: SessionMessage.Message) => { if (message.type === "user") { - const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? [] + const files = + message.files?.map( + (file) => + `[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`, + ) ?? [] return [`[User]: ${message.text}`, ...files].join("\n") } if (message.type === "assistant") { diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 1d0dfc71c3..72622d32ef 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -7,7 +7,7 @@ import type { Database } from "../database/database" import type { EventV2 } from "../event" import { SessionEvent } from "./event" import { SessionMessage } from "./message" -import { Prompt } from "./prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionSchema } from "./schema" import { SessionInputTable, SessionMessageTable } from "./sql" diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts deleted file mode 100644 index b079349c56..0000000000 --- a/packages/core/src/session/prompt.ts +++ /dev/null @@ -1 +0,0 @@ -export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index e6a91a9ef1..d2eaee413b 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -9,16 +9,38 @@ 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 }, }) +const textAttachment = (file: FileAttachment) => + Message.make({ + role: "user", + content: [ + `Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + "", + Buffer.from(file.data, "base64").toString("utf8"), + ] + .filter((line): line is string => line !== undefined) + .join("\n"), + metadata: { + attachment: { + source: file.source, + name: file.name, + description: file.description, + }, + }, + }) + const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const toolInput = (tool: SessionMessage.AssistantTool) => @@ -117,11 +139,18 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] case "model-switched": return [] case "user": + const files = message.files ?? [] return [ + ...files + .filter((file) => file.mime === "text/plain") + .map(textAttachment), Message.make({ id: message.id, role: "user", - content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)], + content: [ + { type: "text", text: message.text }, + ...files.filter((file) => imageMimes.has(file.mime)).map(media), + ], metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index ceef6fe040..3e6f79863c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from import { directoryColumn, pathColumn } from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" -import type { Prompt } from "./prompt" +import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" import type { Snapshot } from "../snapshot" import { PermissionV1 } from "../v1/permission" diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 98551e9f22..df63072fb9 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -16,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1b07937ebd..fc3b4d127d 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -16,7 +16,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -192,7 +192,7 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location, title: "Parent" }) const admitted = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "First" }), + prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -224,9 +224,9 @@ describe("SessionV2.create", () => { promotedSeq: 2, }) - yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false }) + yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false }) + yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) yield* SessionInput.promoteSteers(db, events, forked.id) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -249,13 +249,13 @@ describe("SessionV2.create", () => { const parent = yield* session.create({ location }) const first = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "First" }), + prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) const second = yield* session.prompt({ sessionID: parent.id, - prompt: Prompt.make({ text: "Second" }), + prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) @@ -373,7 +373,7 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false }) + yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) yield* SessionInput.promoteSteers(db, events, created.id) expect( @@ -393,7 +393,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: Prompt.make({ text: "Replay lifecycle" }), + prompt: PromptInput.Prompt.make({ text: "Replay lifecycle" }), resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index da2e494731..77dd272b12 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 9ef5b5d822..0b41dd3735 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,5 +1,9 @@ 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" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -13,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" @@ -169,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, }) @@ -193,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) @@ -205,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( @@ -221,23 +225,161 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service + const uri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" const message = yield* session.prompt({ sessionID, prompt: { text: "Inspect this image", - files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }], + files: [{ uri, name: "image.png", mention: { start: 8, end: 17, text: "[Image 1]" } }], }, resume: false, }) expect(message.prompt.files).toEqual([ - { uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" }, + { + 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("materializes selected source file content", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const directory = import.meta.dir + const source = path.join(directory, "session-prompt.test.ts") + const sourceUri = pathToFileURL(source) + sourceUri.searchParams.set("start", "1") + sourceUri.searchParams.set("end", "1") + + const message = yield* session.prompt({ + sessionID, + prompt: { + text: "Inspect this", + files: [{ uri: sourceUri.href, name: "main.ts" }], + }, + resume: false, + }) + + expect(message.prompt.files).toEqual([ + { + data: Buffer.from('import { describe, expect } from "bun:test"').toString("base64"), + mime: "text/plain", + source: { type: "uri", uri: sourceUri.href }, + name: "main.ts", + }, + ]) + }), + ) + + 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 + const session = yield* SessionV2.Service + const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}` + + const message = yield* session.prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] }, + resume: false, + }) + + 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", + }) + }), + ) + it.effect("streams durable Session events after an aggregate sequence", () => Effect.gen(function* () { yield* setup @@ -251,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)) @@ -276,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, }) @@ -295,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) @@ -313,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, } @@ -333,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) @@ -354,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) @@ -379,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, }) @@ -403,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, } @@ -422,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)], @@ -447,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 @@ -500,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 @@ -534,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 }) @@ -549,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]) @@ -565,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, }) @@ -581,7 +723,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 9aed484858..469d5e0360 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" -import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt" +import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" import { Shell } from "@opencode-ai/schema/shell" @@ -49,7 +49,12 @@ describe("toLLMMessages", () => { }) test("maps every top-level V2 Session message type", () => { - const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const file = FileAttachment.make({ + data: Base64.make("aGVsbG8="), + mime: "image/png", + source: { type: "inline" }, + name: "hello.png", + }) const messages = toLLMMessages( [ SessionMessage.AgentSelected.make({ @@ -122,7 +127,7 @@ describe("toLLMMessages", () => { role: "user", content: [ { type: "text", text: "Inspect this image" }, - { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" }, + { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" }, ], metadata: { agents: [{ name: "build" }] }, }), @@ -149,6 +154,102 @@ Recent work ]) }) + test("lowers text attachments as separate user messages", () => { + const file = FileAttachment.make({ + data: Base64.make(Buffer.from("export const value = 1").toString("base64")), + mime: "text/plain", + source: { type: "uri", uri: "file:///project/main.ts" }, + name: "main.ts", + }) + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-text-file"), + type: "user", + text: "Review this file", + files: [file], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(2) + expect(messages[0]).toMatchObject({ + role: "user", + content: [ + { + type: "text", + text: "Attached file: main.ts\n\nexport const value = 1", + }, + ], + metadata: { attachment: { source: file.source, name: "main.ts" } }, + }) + expect(messages[1]).toMatchObject({ + id: id("user-text-file"), + role: "user", + content: [{ type: "text", text: "Review this file" }], + }) + }) + + 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({ + data: Base64.make(Buffer.from("inline content").toString("base64")), + mime: "text/plain", + source: { type: "inline" }, + name: "inline.txt", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { + type: "text", + text: "Attached file: inline.txt\n\ninline content", + }, + ]) + }) + + test("uses materialized image data as provider media and drops unsupported attachments", () => { + const data = Base64.make("AAECAw==") + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-local-image"), + type: "user", + text: "Inspect this image", + files: [ + FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }), + FileAttachment.make({ + data: Base64.make("JVBERg=="), + mime: "application/pdf", + source: { type: "inline" }, + name: "document.pdf", + }), + ], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "text", text: "Inspect this image" }, + { type: "media", mediaType: "image/png", data, filename: "image.png" }, + ]) + }) + test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 15a1b35fd4..aa2484d34f 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -172,7 +172,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Say hello in one short sentence." }), + prompt: PromptInput.Prompt.make({ text: "Say hello in one short sentence." }), resume: false, }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index d8231dd99b..f90e81d674 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -30,7 +30,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { PromptInput } from "@opencode-ai/schema/prompt-input" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -375,7 +375,7 @@ const setupOverflowRecovery = Effect.gen(function* () { response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Earlier question ".repeat(700) }), + prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }), resume: false, }) yield* session.resume(sessionID) @@ -516,7 +516,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -547,7 +547,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -569,7 +569,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -613,7 +613,7 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use application context" }), resume: false }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -661,7 +661,7 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) }) + const message = yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run automatically" }) }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -675,8 +675,8 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 responses = undefined @@ -703,7 +703,7 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -721,7 +721,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) }) + yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "First" }) }) yield* session.wait(sessionID) expect(requests).toHaveLength(1) @@ -735,7 +735,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) @@ -752,7 +752,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -766,7 +766,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -795,7 +795,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) yield* db @@ -804,7 +804,7 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 yield* session.resume(sessionID) @@ -828,13 +828,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -863,7 +863,7 @@ describe("SessionRunnerLLM", () => { yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents @@ -888,7 +888,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents @@ -912,7 +912,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-build", ["Done"]).completeEvents @@ -938,7 +938,7 @@ describe("SessionRunnerLLM", () => { editor.default(AgentV2.ID.make("reviewer")) }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents @@ -960,7 +960,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents @@ -988,7 +988,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents @@ -1005,7 +1005,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1015,7 +1015,7 @@ describe("SessionRunnerLLM", () => { sessionID, agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1044,7 +1044,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1072,7 +1072,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1088,13 +1088,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1111,7 +1111,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1125,7 +1125,7 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1146,7 +1146,7 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) @@ -1162,20 +1162,20 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1195,7 +1195,7 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1205,7 +1205,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1215,11 +1215,11 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1235,7 +1235,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1252,7 +1252,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1260,7 +1260,7 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1272,7 +1272,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Earlier question ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1285,7 +1285,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1311,7 +1311,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }), + prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1339,7 +1339,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Objective\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", ["## Objective\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", ["## Objective\n- Recover raw overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1415,7 +1415,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1438,7 +1438,7 @@ describe("SessionRunnerLLM", () => { const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1458,13 +1458,13 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { @@ -1478,7 +1478,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) // The rebaseline proceeds while the source is unobservable, restating the model's belief. @@ -1491,7 +1491,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false }) requests.length = 0 responses = undefined @@ -1589,7 +1589,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 authorizations.length = 0 @@ -1648,7 +1648,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 responses = [ @@ -1690,7 +1690,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false }) requests.length = 0 response = [ @@ -1728,7 +1728,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1747,7 +1747,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false }) requests.length = 0 response = [ @@ -1772,7 +1772,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1802,7 +1802,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1863,7 +1863,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1951,7 +1951,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false }) requests.length = 0 responses = undefined @@ -1990,7 +1990,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2010,7 +2010,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2033,7 +2033,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2061,7 +2061,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Wait until continuation ends" }), + prompt: PromptInput.Prompt.make({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -2081,7 +2081,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2099,7 +2099,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Run after interrupt" }), + prompt: PromptInput.Prompt.make({ text: "Run after interrupt" }), delivery: "queue", }) yield* session.interrupt(sessionID) @@ -2124,7 +2124,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -2142,7 +2142,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Steer after interrupt" }), + prompt: PromptInput.Prompt.make({ text: "Steer after interrupt" }), }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -2166,7 +2166,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2191,8 +2191,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2209,10 +2209,10 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Queue for later" }), + prompt: PromptInput.Prompt.make({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2243,7 +2243,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2274,13 +2274,13 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue second" }), delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2308,7 +2308,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2328,8 +2328,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First steer" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second steer" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2348,7 +2348,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = undefined @@ -2359,7 +2359,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover with this" }) }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2378,7 +2378,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), resume: false }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2436,7 +2436,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recover interrupted hosted tool" }), + prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) @@ -2492,7 +2492,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Recover interrupted tool input" }), + prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) @@ -2528,7 +2528,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Wait in queue" }), + prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), delivery: "queue", resume: false, }) @@ -2550,7 +2550,7 @@ describe("SessionRunnerLLM", () => { const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2580,7 +2580,7 @@ describe("SessionRunnerLLM", () => { ) yield* session.prompt({ sessionID, - prompt: Prompt.make({ text: "Run committed promotion" }), + prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }), resume: false, }) @@ -2597,8 +2597,8 @@ describe("SessionRunnerLLM", () => { yield* setup yield* insertSession(otherSessionID) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) + yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false }) requests.length = 0 responses = undefined @@ -2635,12 +2635,12 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID: longSessionID, - prompt: Prompt.make({ text: "Run long session" }), + prompt: PromptInput.Prompt.make({ text: "Run long session" }), resume: false, }) yield* session.prompt({ sessionID: otherLongSessionID, - prompt: Prompt.make({ text: "Run other long session" }), + prompt: PromptInput.Prompt.make({ text: "Run other long session" }), resume: false, }) @@ -2659,7 +2659,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), resume: false }) requests.length = 0 responses = undefined @@ -2690,7 +2690,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false }) requests.length = 0 responses = [ @@ -2736,7 +2736,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false }) requests.length = 0 responses = [ @@ -2926,7 +2926,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 = [ @@ -2978,7 +2978,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 = [ @@ -3017,7 +3017,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), resume: false }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3051,7 +3051,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3101,7 +3101,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() @@ -3129,7 +3129,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() @@ -3179,7 +3179,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 @@ -3227,7 +3227,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 @@ -3255,7 +3255,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 @@ -3273,7 +3273,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 @@ -3295,7 +3295,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" })] @@ -3314,7 +3314,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 = [ @@ -3343,7 +3343,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) @@ -3362,7 +3362,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, }) @@ -3385,7 +3385,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 = [ @@ -3416,7 +3416,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({ @@ -3443,7 +3443,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() @@ -3478,7 +3478,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 @@ -3542,7 +3542,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), resume: false }) responses = undefined streamGate = undefined diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 59abb8fabc..f962672494 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -9,7 +9,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" -import { Prompt } from "@opencode-ai/core/session/prompt" +import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 7d227aac19..d96f6e5bd5 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -6,11 +6,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Agent } from "@opencode-ai/schema/agent" import { Location } from "@opencode-ai/schema/location" import { Model } from "@opencode-ai/schema/model" -import { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" import { Provider } from "@opencode-ai/schema/provider" import { Project } from "@opencode-ai/schema/project" import { ProjectDirectories } from "@opencode-ai/schema/project-directories" import { PermissionV1 } from "@opencode-ai/schema/permission-v1" +import { Prompt } from "@opencode-ai/schema/prompt" import { Session } from "@opencode-ai/schema/session" import { SessionInput } from "@opencode-ai/schema/session-input" import { SessionMessage } from "@opencode-ai/schema/session-message" @@ -47,7 +47,6 @@ test("Core reuses the canonical shared schemas", async () => { coreSessionInput, coreSessionMessage, coreSessionTodo, - corePrompt, coreSkill, coreV2Schema, coreSchema, @@ -69,7 +68,6 @@ test("Core reuses the canonical shared schemas", async () => { import("@opencode-ai/core/session/input"), import("@opencode-ai/core/session/message"), import("@opencode-ai/core/session/todo"), - import("@opencode-ai/core/session/prompt"), import("@opencode-ai/core/skill"), import("@opencode-ai/core/v2-schema"), import("@opencode-ai/core/schema"), @@ -164,10 +162,6 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.Message, SessionMessage.Message], [coreSessionTodo.Info, SessionTodo.Info], [coreSessionTodo.Event, SessionTodo.Event], - [corePrompt.Source, Source], - [corePrompt.FileAttachment, FileAttachment], - [corePrompt.AgentAttachment, AgentAttachment], - [corePrompt.Prompt, Prompt], [coreSkill.DirectorySource, Skill.DirectorySource], [coreSkill.UrlSource, Skill.UrlSource], [coreSkill.EmbeddedSource, Skill.EmbeddedSource], diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index a084c1a819..42028fd726 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -2,3 +2,7 @@ title: "Config" description: "Configure OpenCode." --- + + + You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you. + diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index f218f8ec3f..a9d8d7dcd3 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -1,8 +1,6 @@ import type { - AgentPart, OpencodeClient, V2Event, - FilePart, LspStatus, McpStatus, Todo, @@ -13,9 +11,10 @@ import type { QuestionRequest, Session, SessionStatus, - TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" +import type { PromptInput } from "@opencode-ai/schema" +import type { Types } from "effect" import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { Binding, Keymap } from "@opentui/keymap" import { @@ -180,22 +179,16 @@ export type TuiDialogSelectProps = { current?: Value } -export type TuiPromptInfo = { - input: string +export type TuiPromptInfo = Types.DeepMutable & { + pasted: { + text: string + source: { + start: number + end: number + text: string + } + }[] mode?: "normal" | "shell" - parts: ( - | Omit - | Omit - | (Omit & { - source?: { - text: { - start: number - end: number - value: string - } - } - }) - )[] } export type TuiPromptRef = { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 4e2cc5dd9b..8819d7d9e4 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -264,7 +264,7 @@ export const makeSessionGroup = (sessionLo resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError], + error: [ConflictError, InvalidRequestError, SessionNotFoundError], }) .middleware(sessionLocationMiddleware) .annotateMerge( @@ -290,7 +290,13 @@ export const makeSessionGroup = (sessionLo resume: Schema.Boolean.pipe(Schema.optional), }), success: Schema.Struct({ data: SessionInput.Admitted }), - error: [ConflictError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], + error: [ + ConflictError, + InvalidRequestError, + SessionNotFoundError, + CommandNotFoundError, + CommandEvaluationError, + ], }) .middleware(sessionLocationMiddleware) .annotateMerge( diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1454fab1cf..831f97b066 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -27,6 +27,6 @@ export { Pty } from "./pty.js" export { PtyTicket } from "./pty-ticket.js" export { Question } from "./question.js" export { Workspace } from "./workspace.js" -export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js" +export { Prompt, PromptMention, FileSource, FileAttachment, AgentAttachment } from "./prompt.js" export { PromptInput } from "./prompt-input.js" export * from "./schema.js" diff --git a/packages/schema/src/prompt-input.ts b/packages/schema/src/prompt-input.ts index d573f38555..a52aa23c83 100644 --- a/packages/schema/src/prompt-input.ts +++ b/packages/schema/src/prompt-input.ts @@ -1,7 +1,7 @@ export * as PromptInput from "./prompt-input.js" import { Schema } from "effect" -import { AgentAttachment, Source } from "./prompt.js" +import { AgentAttachment, PromptMention } from "./prompt.js" import { optional, statics } from "./schema.js" export interface FileAttachment extends Schema.Schema.Type {} @@ -9,7 +9,7 @@ export const FileAttachment = Schema.Struct({ uri: Schema.String, name: Schema.String.pipe(optional), description: Schema.String.pipe(optional), - source: Source.pipe(optional), + mention: PromptMention.pipe(optional), }) .annotate({ identifier: "PromptInput.FileAttachment" }) .pipe( diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index ffffebbdf7..3b3a5ac271 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -2,31 +2,46 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { statics } from "./schema.js" -export interface Source extends Schema.Schema.Type {} -export const Source = Schema.Struct({ +export interface PromptMention extends Schema.Schema.Type {} +export const PromptMention = Schema.Struct({ start: Schema.Finite, end: Schema.Finite, text: Schema.String, -}).annotate({ identifier: "Prompt.Source" }) +}).annotate({ identifier: "Prompt.Mention" }) + +export const FileSource = Schema.Union([ + Schema.Struct({ type: Schema.Literal("inline") }), + Schema.Struct({ type: Schema.Literal("uri"), uri: Schema.String }), +]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Prompt.FileSource" }) +export type FileSource = typeof FileSource.Type + +export const Base64 = Schema.String.check( + Schema.isPattern(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), +).annotate({ identifier: "Prompt.Base64" }) +export type Base64 = typeof Base64.Type export interface FileAttachment extends Schema.Schema.Type {} export const FileAttachment = Schema.Struct({ - uri: Schema.String, + data: Base64, mime: Schema.String, + 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, + source: input.source, name: input.name, description: input.description, - source: input.source, + mention: input.mention, }), })), ) @@ -34,7 +49,7 @@ export const FileAttachment = Schema.Struct({ export interface AgentAttachment extends Schema.Schema.Type {} export const AgentAttachment = Schema.Struct({ name: Schema.String, - source: Source.pipe(optional), + mention: PromptMention.pipe(optional), }).annotate({ identifier: "Prompt.AgentAttachment" }) export interface Prompt extends Schema.Schema.Type {} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index fc23219fc4..4101021152 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -8,6 +8,7 @@ export { Location, Model, Prompt, + PromptInput, Provider, RelativePath, Session, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index cd9cd6a50d..b0efa62909 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -181,7 +181,7 @@ it.live( const active = yield* opencode.sessions.active() const admitted = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.Prompt.make({ text: "Do not run" }), + prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Do not run" }), resume: false, }) const context = yield* opencode.sessions.context({ sessionID: id }) @@ -192,7 +192,7 @@ it.live( const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id }) const wake = yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), + prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Promote this input" }), }) const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( Stream.filter((event) => event.type === "session.prompt.promoted" && event.data.inputID === wake.id), @@ -282,7 +282,7 @@ it.live( yield* opencode.sessions.create({ id, location: location(fixture) }) yield* opencode.sessions.prompt({ sessionID: id, - prompt: fixture.sdk.Prompt.make({ text: "Observe this input" }), + prompt: fixture.sdk.PromptInput.Prompt.make({ text: "Observe this input" }), }) const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 443d4d4cce..f1215bf713 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3255,23 +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 + source: PromptFileSource name?: string description?: string - source?: PromptSource + mention?: PromptMention } export type PromptAgentAttachment = { name: string - source?: PromptSource + mention?: PromptMention } export type SessionErrorUnknown = { @@ -4278,7 +4290,7 @@ export type PromptInputFileAttachment = { uri: string name?: string description?: string - source?: PromptSource + mention?: PromptMention } export type SessionInputAdmitted = { @@ -7876,7 +7888,7 @@ export type MessageNotFoundErrorV2 = { message: string } -export type PromptSource2 = { +export type PromptMention2 = { start: number end: number text: string @@ -7886,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 = { @@ -7900,12 +7912,24 @@ export type PromptInputV2 = { agents?: Array } +export type PromptBase642 = string + +export type PromptFileSource2 = + | { + type: "inline" + } + | { + type: "uri" + uri: string + } + export type PromptFileAttachment2 = { - uri: string + data: PromptBase642 mime: string + source: PromptFileSource2 name?: string description?: string - source?: PromptSource2 + mention?: PromptMention2 } export type PromptV2 = { @@ -15963,7 +15987,7 @@ export type V2SessionPromptErrors = { /** * InvalidRequestError */ - 400: InvalidRequestErrorV2 + 400: InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ @@ -16014,7 +16038,7 @@ export type V2SessionCommandErrors = { /** * InvalidRequestError */ - 400: InvalidRequestErrorV2 + 400: InvalidRequestError1 | InvalidRequestErrorV2 /** * UnauthorizedError */ diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2bb45d52ed..aa79b8bd51 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -9,6 +9,7 @@ import { CommandEvaluationError, CommandNotFoundError, InvalidCursorError, + InvalidRequestError, MessageNotFoundError, ServiceUnavailableError, SessionBusyError, @@ -213,6 +214,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.AttachmentError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })), + ), ), } }), @@ -267,6 +271,9 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl }), ), ), + Effect.catchTag("Session.AttachmentError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })), + ), ), } }), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1c8e3663eb..67d16c1c48 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1039,7 +1039,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi enabled: () => { const current = promptRef.current if (!current?.focused) return true - return current.current.input === "" + return current.current.text === "" }, bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), })) diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index b306087c91..b08886f79f 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -40,9 +40,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { return entries .map((entry, index) => { const isDeleting = toDelete() === index - const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 + const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input), + title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index fb9d1537e9..65bff40838 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -18,7 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" -import type { PromptInfo } from "../../prompt/history" +import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" @@ -76,7 +76,7 @@ export function Autocomplete(props: { value: string sessionID?: string setPrompt: (input: (prompt: PromptInfo) => void) => void - setExtmark: (partIndex: number, extmarkId: number) => void + setExtmark: (part: PromptPartRef, extmarkId: number) => void anchor: () => BoxRenderable input: () => TextareaRenderable ref: (ref: AutocompleteRef) => void @@ -169,7 +169,12 @@ export function Autocomplete(props: { setStore("input", "keyboard") }) - function insertPart(text: string, part: PromptInfo["parts"][number]) { + function insertPart( + text: string, + part: + | { type: "file"; value: NonNullable[number]; path?: string } + | { type: "agent"; value: NonNullable[number] }, + ) { const input = props.input() const currentCursorOffset = input.cursorOffset @@ -189,7 +194,7 @@ export function Autocomplete(props: { const extmarkStart = store.index const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) - const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined + const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId const extmarkId = input.extmarks.create({ start: extmarkStart, @@ -201,42 +206,40 @@ export function Autocomplete(props: { props.setPrompt((draft) => { if (part.type === "file") { - const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url) + const files = (draft.files ??= []) + const existingIndex = files.findIndex((file) => file.uri === part.value.uri) if (existingIndex !== -1) { - const existing = draft.parts[existingIndex] - if ( - part.source?.text && - existing && - "source" in existing && - existing.source && - "text" in existing.source && - existing.source.text - ) { - existing.source.text.start = extmarkStart - existing.source.text.end = extmarkEnd - existing.source.text.value = virtualText + const existing = files[existingIndex] + if (existing?.mention) { + existing.mention.start = extmarkStart + existing.mention.end = extmarkEnd + existing.mention.text = virtualText } return } + 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) + props.setExtmark({ type: "file", index }, extmarkId) + return } - if (part.type === "file" && part.source?.text) { - part.source.text.start = extmarkStart - part.source.text.end = extmarkEnd - part.source.text.value = virtualText - } else if (part.type === "agent" && part.source) { - part.source.start = extmarkStart - part.source.end = extmarkEnd - part.source.value = virtualText + const agents = (draft.agents ??= []) + if (part.value.mention) { + part.value.mention.start = extmarkStart + part.value.mention.end = extmarkEnd + part.value.mention.text = virtualText } - const partIndex = draft.parts.length - draft.parts.push(part) - props.setExtmark(partIndex, extmarkId) + const index = agents.length + agents.push(part.value) + props.setExtmark({ type: "agent", index }, extmarkId) }) - if (part.type === "file" && part.source && part.source.type === "file") { - frecency.updateFrecency(part.source.path) - } + if (part.type === "file" && part.path) frecency.updateFrecency(part.path) } function createFilePart( @@ -261,17 +264,11 @@ export function Autocomplete(props: { filename, part: { type: "file" as const, - mime: item.type === "directory" ? "application/x-directory" : "text/plain", - filename, - url: urlObj.href, - source: { - type: "file" as const, - text: { - start: 0, - end: 0, - value: "", - }, - path: item.path, + path: item.path, + value: { + uri: urlObj.href, + name: filename, + mention: { start: 0, end: 0, text: "" }, }, }, } @@ -375,18 +372,11 @@ export function Autocomplete(props: { onSelect: () => { insertPart(res.name, { type: "file", - mime: res.mimeType ?? "text/plain", - filename: res.name, - url: res.uri, - source: { - type: "resource", - text: { - start: 0, - end: 0, - value: "", - }, - clientName: res.client, + value: { uri: res.uri, + name: res.name, + description: res.description, + mention: { start: 0, end: 0, text: "" }, }, }) }, @@ -405,11 +395,9 @@ export function Autocomplete(props: { onSelect: () => { insertPart(agent.id, { type: "agent", - name: agent.id, - source: { - start: 0, - end: 0, - value: "", + value: { + name: agent.id, + mention: { start: 0, end: 0, text: "" }, }, }) }, @@ -427,13 +415,11 @@ export function Autocomplete(props: { onSelect: () => { insertPart(reference.name, { type: "file", - mime: "application/x-directory", - filename: reference.name, - url: pathToFileURL(reference.path).href, - source: { - type: "file", - text: { start: 0, end: 0, value: "" }, - path: reference.name, + path: reference.name, + value: { + uri: pathToFileURL(reference.path).href, + name: reference.name, + mention: { start: 0, end: 0, text: "" }, }, }) }, @@ -667,7 +653,7 @@ export function Autocomplete(props: { props.input().deleteRange(0, 0, cursor.row, cursor.col) // Sync the prompt store immediately since onContentChange is async props.setPrompt((draft) => { - draft.input = props.input().plainText + draft.text = props.input().plainText }) } setStore("visible", false) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1b742a76ab..7aac079b03 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -30,14 +30,14 @@ import { normalizePromptContent, openEditor } from "../../editor" import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" -import { usePromptHistory, type PromptInfo } from "../../prompt/history" +import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -313,17 +313,14 @@ export function Prompt(props: PromptProps) { const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" - extmarkToPartIndex: Map + extmarkToPart: Map interrupt: number placeholder: number }>({ placeholder: randomIndex(list().length), - prompt: { - input: "", - parts: [], - }, + prompt: emptyPrompt(), mode: "normal", - extmarkToPartIndex: new Map(), + extmarkToPart: new Map(), interrupt: 0, }) @@ -400,8 +397,7 @@ export function Prompt(props: PromptProps) { if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", - mime: content.mime, - content: content.data, + uri: `data:${content.mime};base64,${content.data}`, }) return } @@ -467,14 +463,10 @@ export function Prompt(props: PromptProps) { dialog.clear() // replace summarized text parts with the actual text - const text = store.prompt.parts - .filter((p) => p.type === "text") - .reduce((acc, p) => { - if (!p.source) return acc - return acc.replace(p.source.text.value, p.text) - }, store.prompt.input) - - const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") + const text = store.prompt.pasted.reduce( + (result, part) => result.replace(part.source.text, part.text), + store.prompt.text, + ) const value = text const content = await openEditor({ @@ -490,63 +482,23 @@ export function Prompt(props: PromptProps) { input.setText(normalized) - // Update positions for nonTextParts based on their location in new content - // Filter out parts whose virtual text was deleted + // 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 updatedNonTextParts = nonTextParts - .map((part) => { - let virtualText = "" - if (part.type === "file" && part.source?.text) { - virtualText = part.source.text.value - } else if (part.type === "agent" && part.source) { - virtualText = part.source.value - } - - if (!virtualText) return part - - const newStart = normalized.indexOf(virtualText) - // if the virtual text is deleted, remove the part - if (newStart === -1) return null - - const newEnd = newStart + virtualText.length - - if (part.type === "file" && part.source?.text) { - return { - ...part, - source: { - ...part.source, - text: { - ...part.source.text, - start: newStart, - end: newEnd, - }, - }, - } - } - - if (part.type === "agent" && part.source) { - return { - ...part, - source: { - ...part.source, - start: newStart, - end: newEnd, - }, - } - } - - return part - }) - .filter((part) => part !== null) + const moveMention = (part: Part) => { + if (!part.mention?.text) return part + const start = normalized.indexOf(part.mention.text) + if (start === -1) return + return { ...part, mention: { ...part.mention, start, end: start + part.mention.text.length } } + } setStore("prompt", { - input: normalized, - // keep only the non-text parts because the text parts were - // already expanded inline - parts: updatedNonTextParts, + text: normalized, + files: store.prompt.files?.map(moveMention).filter((part) => part !== undefined), + agents: store.prompt.agents?.map(moveMention).filter((part) => part !== undefined), + pasted: [], }) - restoreExtmarksFromParts(updatedNonTextParts) + restoreExtmarksFromPrompt(store.prompt) input.cursorOffset = Bun.stringWidth(normalized) }, }, @@ -562,8 +514,8 @@ export function Prompt(props: PromptProps) { onSelect={(skill) => { input.setText(`/${skill} `) setStore("prompt", { - input: `/${skill} `, - parts: [], + ...emptyPrompt(), + text: `/${skill} `, }) input.gotoBufferEnd() }} @@ -633,19 +585,16 @@ export function Prompt(props: PromptProps) { input.blur() }, set(prompt) { - input.setText(prompt.input) + input.setText(prompt.text) setStore("prompt", prompt) - restoreExtmarksFromParts(prompt.parts) + restoreExtmarksFromPrompt(prompt) input.gotoBufferEnd() }, reset() { input.clear() input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) }, submit() { void submit() @@ -655,17 +604,17 @@ export function Prompt(props: PromptProps) { onMount(() => { const saved = stashed stashed = undefined - if (store.prompt.input) return - if (saved && saved.prompt.input) { - input.setText(saved.prompt.input) + if (store.prompt.text) return + if (saved && saved.prompt.text) { + input.setText(saved.prompt.text) setStore("prompt", saved.prompt) - restoreExtmarksFromParts(saved.prompt.parts) + restoreExtmarksFromPrompt(saved.prompt) input.cursorOffset = saved.cursor } }) onCleanup(() => { - if (store.prompt.input) { + if (store.prompt.text) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } setInputTarget(undefined) @@ -695,44 +644,40 @@ export function Prompt(props: PromptProps) { } }) - function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { + function restoreExtmarksFromPrompt(prompt: PromptInfo) { input.extmarks.clear() - setStore("extmarkToPartIndex", new Map()) + setStore("extmarkToPart", new Map()) - parts.forEach((part, partIndex) => { - let start = 0 - let end = 0 - let virtualText = "" - let styleId: number | undefined + const parts = [ + ...(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, + })), + ] - if (part.type === "file" && part.source?.text) { - start = part.source.text.start - end = part.source.text.end - virtualText = part.source.text.value - styleId = fileStyleId - } else if (part.type === "agent" && part.source) { - start = part.source.start - end = part.source.end - virtualText = part.source.value - styleId = agentStyleId - } else if (part.type === "text" && part.source?.text) { - start = part.source.text.start - end = part.source.text.end - virtualText = part.source.text.value - styleId = pasteStyleId - } - - if (virtualText) { + parts.forEach(({ mention, ref, styleId }) => { + if (mention?.text) { const extmarkId = input.extmarks.create({ - start, - end, + start: mention.start, + end: mention.end, virtual: true, styleId, typeId: promptPartTypeId, }) - setStore("extmarkToPartIndex", (map: Map) => { + setStore("extmarkToPart", (map: Map) => { const newMap = new Map(map) - newMap.set(extmarkId, partIndex) + newMap.set(extmarkId, ref) return newMap }) } @@ -743,32 +688,47 @@ export function Prompt(props: PromptProps) { const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) setStore( produce((draft) => { - const newMap = new Map() - const newParts: typeof draft.prompt.parts = [] + const newMap = new Map() + const files: NonNullable = [] + const agents: NonNullable = [] + const pasted: PromptInfo["pasted"] = [] for (const extmark of allExtmarks) { - const partIndex = draft.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = draft.prompt.parts[partIndex] - if (part) { - if (part.type === "agent" && part.source) { - part.source.start = extmark.start - part.source.end = extmark.end - } else if (part.type === "file" && part.source?.text) { - part.source.text.start = extmark.start - part.source.text.end = extmark.end - } else if (part.type === "text" && part.source?.text) { - part.source.text.start = extmark.start - part.source.text.end = extmark.end - } - newMap.set(extmark.id, newParts.length) - newParts.push(part) - } + const ref = draft.extmarkToPart.get(extmark.id) + if (!ref) continue + if (ref.type === "file") { + const part = draft.prompt.files?.[ref.index] + 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 }) + continue } + if (ref.type === "agent") { + const part = draft.prompt.agents?.[ref.index] + 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 }) + continue + } + const part = draft.prompt.pasted[ref.index] + if (!part) continue + part.source.start = extmark.start + part.source.end = extmark.end + const index = pasted.length + pasted.push(part) + newMap.set(extmark.id, { type: "pasted", index }) } - draft.extmarkToPartIndex = newMap - draft.prompt.parts = newParts + draft.extmarkToPart = newMap + draft.prompt.files = files + draft.prompt.agents = agents + draft.prompt.pasted = pasted }), ) } @@ -779,17 +739,14 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", name: "prompt.stash", category: "Prompt", - enabled: !!store.prompt.input, + enabled: !!store.prompt.text, run: () => { - if (!store.prompt.input) return - stash.push({ - input: store.prompt.input, - parts: store.prompt.parts, - }) + if (!store.prompt.text) return + stash.push({ prompt: store.prompt }) input.extmarks.clear() input.clear() - setStore("prompt", { input: "", parts: [] }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) dialog.clear() }, }, @@ -801,9 +758,9 @@ export function Prompt(props: PromptProps) { run: () => { const entry = stash.pop() if (entry) { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) + input.setText(entry.prompt.text) + setStore("prompt", entry.prompt) + restoreExtmarksFromPrompt(entry.prompt) input.gotoBufferEnd() } dialog.clear() @@ -818,9 +775,9 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) + input.setText(entry.prompt.text) + setStore("prompt", entry.prompt) + restoreExtmarksFromPrompt(entry.prompt) input.gotoBufferEnd() }} /> @@ -848,7 +805,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "", bindings: tuiConfig.keybinds.get("prompt.clear"), } }) @@ -919,10 +876,10 @@ export function Prompt(props: PromptProps) { const item = history.move(-1, input.plainText) if (!item) return false - input.setText(item.input) + input.setText(item.text) setStore("prompt", item) setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) + restoreExtmarksFromPrompt(item) input.cursorOffset = 0 }, }, @@ -955,10 +912,10 @@ export function Prompt(props: PromptProps) { const item = history.move(1, input.plainText) if (!item) return false - input.setText(item.input) + input.setText(item.text) setStore("prompt", item) setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) + restoreExtmarksFromPrompt(item) input.cursorOffset = input.plainText.length }, }, @@ -972,7 +929,7 @@ export function Prompt(props: PromptProps) { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, // a second call slips past the empty-input check before the first call - // clears `store.prompt.input`, then awaits its own `session.create` and + // clears `store.prompt.text`, then awaits its own `session.create` and // ultimately reads the now-empty store — sending a phantom empty prompt // to a freshly created session. if (submitting) return false @@ -990,17 +947,17 @@ export function Prompt(props: PromptProps) { // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. - if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { - setStore("prompt", "input", input.plainText) + if (input && !input.isDestroyed && input.plainText !== store.prompt.text) { + setStore("prompt", "text", input.plainText) syncExtmarksWithPromptParts() } if (props.disabled) return false if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false - if (!store.prompt.input) return false + if (!store.prompt.text) return false const agent = local.agent.current() if (!agent) return false - const trimmed = store.prompt.input.trim() + const trimmed = store.prompt.text.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true @@ -1034,7 +991,7 @@ export function Prompt(props: PromptProps) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory(store.prompt.input) + const directory = await move.getDirectory(store.prompt.text) if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const location = data.location.default() @@ -1069,18 +1026,16 @@ export function Prompt(props: PromptProps) { } const inputText = expandTrackedPastedText( - store.prompt.input, + store.prompt.text, input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] - if (part?.type !== "text") return [] + const ref = store.extmarkToPart.get(extmark.id) + if (ref?.type !== "pasted") return [] + const part = store.prompt.pasted[ref.index] + if (!part) return [] return [{ start: extmark.start, end: extmark.end, text: part.text }] }), ) - // Filter out text parts (pasted content) since they're now expanded inline - const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") - // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() @@ -1129,35 +1084,8 @@ export function Prompt(props: PromptProps) { arguments: args, agent: agent.id, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), + files: store.prompt.files, + agents: store.prompt.agents, }) .catch((error) => { toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) @@ -1207,35 +1135,8 @@ export function Prompt(props: PromptProps) { sessionID, prompt: { text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), + files: store.prompt.files, + agents: store.prompt.agents, }, }) .then( @@ -1253,11 +1154,8 @@ export function Prompt(props: PromptProps) { mode: currentMode, }) input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) props.onSubmit?.() // temporary hack to make sure the message is sent @@ -1292,19 +1190,12 @@ export function Prompt(props: PromptProps) { setStore( produce((draft) => { - const partIndex = draft.prompt.parts.length - draft.prompt.parts.push({ - type: "text" as const, + const index = draft.prompt.pasted.length + draft.prompt.pasted.push({ text, - source: { - text: { - start: extmarkStart, - end: extmarkEnd, - value: virtualText, - }, - }, + source: { start: extmarkStart, end: extmarkEnd, text: virtualText }, }) - draft.extmarkToPartIndex.set(extmarkId, partIndex) + draft.extmarkToPart.set(extmarkId, { type: "pasted", index }) }), ) } @@ -1324,9 +1215,7 @@ export function Prompt(props: PromptProps) { if (attachment?.type === "binary") { await pasteAttachment({ filename, - filepath, - mime: attachment.mime, - content: Buffer.from(attachment.content).toString("base64"), + uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, }) return } @@ -1350,15 +1239,12 @@ export function Prompt(props: PromptProps) { }, 0) } - async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { + async function pasteAttachment(file: { filename?: string; uri: string }) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset - const pdf = file.mime === "application/pdf" - const count = store.prompt.parts.filter((x) => { - if (x.type !== "file") return false - if (pdf) return x.mime === "application/pdf" - return x.mime.startsWith("image/") - }).length + const pdf = file.uri.startsWith("data:application/pdf;") + const prefix = pdf ? "data:application/pdf;" : "data:image/" + const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0 const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " @@ -1373,33 +1259,33 @@ export function Prompt(props: PromptProps) { typeId: promptPartTypeId, }) - const part: Omit = { - type: "file" as const, - mime: file.mime, - filename: file.filename, - url: `data:${file.mime};base64,${file.content}`, - source: { - type: "file", - path: file.filepath ?? file.filename ?? "", - text: { - start: extmarkStart, - end: extmarkEnd, - value: virtualText, - }, + const part: NonNullable[number] = { + uri: file.uri, + name: file.filename, + mention: { + start: extmarkStart, + end: extmarkEnd, + text: virtualText, }, } setStore( produce((draft) => { - const partIndex = draft.prompt.parts.length - draft.prompt.parts.push(part) - draft.extmarkToPartIndex.set(extmarkId, partIndex) + const files = (draft.prompt.files ??= []) + const index = files.length + files.push(part) + draft.extmarkToPart.set(extmarkId, { type: "file", index }) }), ) return } function clearPrompt() { - if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + if ( + store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS || + store.prompt.pasted.length > 0 || + (store.prompt.files?.length ?? 0) > 0 || + (store.prompt.agents?.length ?? 0) > 0 + ) { history.append({ ...store.prompt, mode: store.mode, @@ -1407,11 +1293,8 @@ export function Prompt(props: PromptProps) { } input.clear() input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) } const highlight = createMemo(() => { @@ -1505,7 +1388,7 @@ export function Prompt(props: PromptProps) { maxHeight={maxHeight()} onContentChange={() => { const value = input.plainText - setStore("prompt", "input", value) + setStore("prompt", "text", value) auto()?.onInput(value) syncExtmarksWithPromptParts() setCursorVersion((value) => value + 1) @@ -1550,7 +1433,7 @@ export function Prompt(props: PromptProps) { ref={(r: TextareaRenderable) => { input = r Object.assign(r, { - getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts), + getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted), }) setInputTarget(r) if (promptPartTypeId === 0) { @@ -1763,14 +1646,14 @@ export function Prompt(props: PromptProps) { setPrompt={(cb) => { setStore("prompt", produce(cb)) }} - setExtmark={(partIndex, extmarkId) => { - setStore("extmarkToPartIndex", (map: Map) => { + setExtmark={(part, extmarkId) => { + setStore("extmarkToPart", (map: Map) => { const newMap = new Map(map) - newMap.set(extmarkId, partIndex) + newMap.set(extmarkId, part) return newMap }) }} - value={store.prompt.input} + value={store.prompt.text} fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index e795d11da4..fa69fedbb2 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -1,29 +1,33 @@ import path from "path" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" -import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" +import type { SessionPromptInput } from "@opencode-ai/client/promise" +import type { Types } from "effect" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" -export type PromptInfo = { - input: string - mode?: "normal" | "shell" - parts: ( - | Omit - | Omit - | (Omit & { - source?: { - text: { - start: number - end: number - value: string - } - } - }) - )[] +export type PastedText = { + text: string + source: { + start: number + end: number + text: string + } } +export type PromptInfo = Types.DeepMutable & { + pasted: PastedText[] + mode?: "normal" | "shell" +} + +export type PromptPartRef = { + type: "file" | "agent" | "pasted" + index: number +} + +export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] }) + export const MAX_HISTORY_ENTRIES = 50 export function parsePromptHistory(text: string) { @@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) { .filter(Boolean) .map((line) => { try { - return JSON.parse(line) as PromptInfo + return parsePromptInfo(JSON.parse(line)) } catch { return undefined } @@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI return JSON.stringify(previous) === JSON.stringify(next) } +export function parsePromptInfo(value: unknown): PromptInfo | undefined { + if (!value || typeof value !== "object") return + const input = value as Record + if (typeof input.text !== "string" || !Array.isArray(input.pasted)) return + return input as PromptInfo +} + export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { @@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (!store.history.length) return undefined const current = store.history.at(store.index) if (!current) return undefined - if (current.input !== input && input.length) return + if (current.text !== input && input.length) return setStore( produce((draft) => { const next = store.index + direction @@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create draft.index = next }), ) - if (store.index === 0) return { input: "", parts: [] } + if (store.index === 0) return emptyPrompt() return store.history.at(store.index) }, append(item: PromptInfo) { diff --git a/packages/tui/src/prompt/part.ts b/packages/tui/src/prompt/part.ts index 0027b9aaed..179c527242 100644 --- a/packages/tui/src/prompt/part.ts +++ b/packages/tui/src/prompt/part.ts @@ -1,24 +1,10 @@ import { displaySlice } from "./display" -export function stripPromptPartIDs(part: Part) { - const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part - return rest -} - -export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) { - return parts.reduce((result, part) => { - if (!isPastedTextPart(part)) return result - return result.replace(part.source.text.value, part.text) - }, text) -} - -function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } { - if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false - if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false - const source = part.source - if (!source || typeof source !== "object" || !("text" in source)) return false - const text = source.text - return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string") +export function expandPastedTextPlaceholders( + text: string, + pasted: readonly { text: string; source: { text: string } }[], +) { + return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text) } export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) { diff --git a/packages/tui/src/prompt/stash.tsx b/packages/tui/src/prompt/stash.tsx index d43ee0eb42..46a44ccfd6 100644 --- a/packages/tui/src/prompt/stash.tsx +++ b/packages/tui/src/prompt/stash.tsx @@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" -import type { PromptInfo } from "./history" +import { parsePromptInfo, type PromptInfo } from "./history" export type StashEntry = { - input: string - parts: PromptInfo["parts"] + prompt: PromptInfo timestamp: number } @@ -20,7 +19,12 @@ export function parsePromptStash(text: string) { .filter(Boolean) .map((line) => { try { - return JSON.parse(line) as StashEntry + const value = JSON.parse(line) as unknown + if (!value || typeof value !== "object") return + const entry = value as Record + const prompt = parsePromptInfo(entry.prompt) + if (!prompt || typeof entry.timestamp !== "number") return + return { prompt, timestamp: entry.timestamp } } catch { return undefined } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87..d4074da3a5 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -51,7 +51,7 @@ export function Home() { return } if (!args.prompt) return - r.set({ input: args.prompt, parts: [] }) + r.set({ text: args.prompt, files: [], agents: [], pasted: [] }) once = true } @@ -62,7 +62,7 @@ export function Home() { if (!r) return if (!sync.ready || !local.model.ready) return if (!args.prompt) return - if (r.current.input !== args.prompt) return + if (r.current.text !== args.prompt) return sent = true r.submit() }) diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx index 4cc9b59c00..d0952a907e 100644 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx @@ -6,8 +6,7 @@ import { Locale } from "../../util/locale" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useDialog, type DialogContext } from "../../ui/dialog" -import type { PromptInfo } from "../../component/prompt/history" -import { stripPromptPartIDs as strip } from "../../prompt/part" +import { emptyPrompt, type PromptInfo } from "../../component/prompt/history" export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { const sync = useSync() @@ -53,12 +52,25 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess const prompt = parts.reduce( (agg, part) => { if (part.type === "text") { - if (!part.synthetic) agg.input += part.text + if (!part.synthetic) agg.text += part.text + } + if (part.type === "file") { + const files = (agg.files ??= []) + files.push({ + uri: part.url, + name: part.filename, + mention: part.source?.text + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }) } - if (part.type === "file") agg.parts.push(strip(part)) return agg }, - { input: "", parts: [] as PromptInfo["parts"] }, + emptyPrompt() as PromptInfo, ) route.navigate({ sessionID: forked.data!.id, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 3a39492d2a..43103c3d84 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1420,15 +1420,15 @@ function UserMessage(props: { message: SessionMessageUser }) { {(file) => { - const directory = file.mime === "application/x-directory" + const label = file.mime === "application/x-directory" ? "Directory" : file.mime return ( - {directory ? " Directory " : " File "} + {` ${label} `} {" "} - {file.name ?? file.uri}{" "} + {file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "} ) diff --git a/packages/tui/test/cli/tui/prompt-submit-race.test.ts b/packages/tui/test/cli/tui/prompt-submit-race.test.ts index fb85a7f5b1..c758762bfb 100644 --- a/packages/tui/test/cli/tui/prompt-submit-race.test.ts +++ b/packages/tui/test/cli/tui/prompt-submit-race.test.ts @@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test" // // Before the fix, two concurrent `submit()` calls (e.g. a double-pressed // Enter, or the input's native onSubmit racing another dispatch) each -// passed the `if (!store.prompt.input) return false` guard, each +// passed the `if (!store.prompt.text) return false` guard, each // `await sdk.client.session.create(...)`, and each only captured -// `inputText = store.prompt.input` AFTER that await. The first invocation +// `inputText = store.prompt.text` AFTER that await. The first invocation // finished, sent the prompt, and cleared the store; the second invocation, // now past its await, read the cleared store and sent an empty prompt to a // second freshly-created session - leaving an orphaned session with the diff --git a/packages/tui/test/prompt/history.test.ts b/packages/tui/test/prompt/history.test.ts index ebd3660d40..1442f8fdf9 100644 --- a/packages/tui/test/prompt/history.test.ts +++ b/packages/tui/test/prompt/history.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history" -const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts }) +const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({ + text, + files, + agents: [], + pasted: [], +}) describe("prompt history", () => { test("recovers valid JSONL entries around corruption", () => { @@ -11,13 +16,17 @@ describe("prompt history", () => { ]) }) + test("ignores the legacy parts shape", () => { + expect(parsePromptHistory(JSON.stringify({ input: "old", parts: [] }))).toEqual([]) + }) + test("retains only the newest entries", () => { const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) => JSON.stringify(entry(String(index))), ).join("\n") const result = parsePromptHistory(input) expect(result).toHaveLength(MAX_HISTORY_ENTRIES) - expect(result[0]?.input).toBe("5") + expect(result[0]?.text).toBe("5") }) test("dedupes only identical consecutive entries", () => { @@ -27,13 +36,10 @@ describe("prompt history", () => { expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false) }) - test("does not dedupe entries with different parts", () => { - const a = entry("describe this", [ - { type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" }, - ]) - const b = entry("describe this", [ - { type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" }, - ]) + test("does not dedupe entries with different attachments", () => { + const a = entry("describe this", [{ name: "a.png", uri: "data:image/png;base64,AAA" }]) + const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }]) expect(isDuplicateEntry(a, b)).toBe(false) }) + }) diff --git a/packages/tui/test/prompt/jsonl.test.ts b/packages/tui/test/prompt/jsonl.test.ts index e8afd4aefb..347a359144 100644 --- a/packages/tui/test/prompt/jsonl.test.ts +++ b/packages/tui/test/prompt/jsonl.test.ts @@ -4,12 +4,12 @@ import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash" test("stash JSONL skips corruption and retains newest entries", () => { const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) => - JSON.stringify({ input: String(index), parts: [], timestamp: index }), + JSON.stringify({ prompt: { text: String(index), files: [], agents: [], pasted: [] }, timestamp: index }), ) entries.splice(2, 0, "broken") const result = parsePromptStash(entries.join("\n")) expect(result).toHaveLength(MAX_STASH_ENTRIES) - expect(result[0]?.input).toBe("2") + expect(result[0]?.prompt.text).toBe("2") }) test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => { diff --git a/packages/tui/test/prompt/part.test.ts b/packages/tui/test/prompt/part.test.ts index 2d5605ac0c..91bbc8e258 100644 --- a/packages/tui/test/prompt/part.test.ts +++ b/packages/tui/test/prompt/part.test.ts @@ -1,26 +1,7 @@ import { describe, expect, test } from "bun:test" -import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part" +import { expandTrackedPastedText } from "../../src/prompt/part" describe("prompt part", () => { - test("strips persisted IDs from reused parts", () => { - expect( - stripPromptPartIDs({ - id: "prt_old", - sessionID: "ses_old", - messageID: "msg_old", - type: "file" as const, - mime: "image/png", - filename: "tiny.png", - url: "data:image/png;base64,abc", - }), - ).toEqual({ - type: "file", - mime: "image/png", - filename: "tiny.png", - url: "data:image/png;base64,abc", - }) - }) - test("preserves wide characters around pasted text", () => { const marker = "[Pasted ~3 lines]" const prefix = "你好你好\n"