import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" import z from "zod" import { NamedError } from "@opencode-ai/shared/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { SyncEvent } from "../sync" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage" import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import { EffectLogger } from "@/effect" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { code: "ZlibError" errno: number path: string } export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" export { isMedia } export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const StructuredOutputError = NamedError.create( "StructuredOutputError", z.object({ message: z.string(), retries: z.number(), }), ) export const AuthError = NamedError.create( "ProviderAuthError", z.object({ providerID: z.string(), message: z.string(), }), ) export const APIError = NamedError.create( "APIError", z.object({ message: z.string(), statusCode: z.number().optional(), isRetryable: z.boolean(), responseHeaders: z.record(z.string(), z.string()).optional(), responseBody: z.string().optional(), metadata: z.record(z.string(), z.string()).optional(), }), ) export type APIError = z.infer export const ContextOverflowError = NamedError.create( "ContextOverflowError", z.object({ message: z.string(), responseBody: z.string().optional() }), ) export class OutputFormatText extends Schema.Class("OutputFormatText")({ type: Schema.Literal("text"), }) { static readonly zod = zod(this) } export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), retryCount: Schema.Number.check(Schema.isInt()) .check(Schema.isGreaterThanOrEqualTo(0)) .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), }) { static readonly zod = zod(this) } const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ discriminator: "type", identifier: "OutputFormat", }) export const Format = Object.assign(_Format, { zod: zod(_Format) }) export type OutputFormat = Schema.Schema.Type const partBase = { id: PartID, sessionID: SessionID, messageID: MessageID, } export const SnapshotPart = Schema.Struct({ ...partBase, type: Schema.Literal("snapshot"), snapshot: Schema.String, }) .annotate({ identifier: "SnapshotPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SnapshotPart = Types.DeepMutable> export const PatchPart = Schema.Struct({ ...partBase, type: Schema.Literal("patch"), hash: Schema.String, files: Schema.Array(Schema.String), }) .annotate({ identifier: "PatchPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type PatchPart = Types.DeepMutable> export const TextPart = Schema.Struct({ ...partBase, type: Schema.Literal("text"), text: Schema.String, synthetic: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "TextPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type TextPart = Types.DeepMutable> export const ReasoningPart = Schema.Struct({ ...partBase, type: Schema.Literal("reasoning"), text: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), }) .annotate({ identifier: "ReasoningPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ReasoningPart = Types.DeepMutable> const filePartSourceBase = { text: Schema.Struct({ value: Schema.String, start: Schema.Number.check(Schema.isInt()), end: Schema.Number.check(Schema.isInt()), }).annotate({ identifier: "FilePartSourceText" }), } export const FileSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("file"), path: Schema.String, }) .annotate({ identifier: "FileSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export const SymbolSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("symbol"), path: Schema.String, range: LSP.Range, name: Schema.String, kind: Schema.Number.check(Schema.isInt()), }) .annotate({ identifier: "SymbolSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export const ResourceSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("resource"), clientName: Schema.String, uri: Schema.String, }) .annotate({ identifier: "ResourceSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ discriminator: "type", identifier: "FilePartSource", }) export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) }) export const FilePart = Schema.Struct({ ...partBase, type: Schema.Literal("file"), mime: Schema.String, filename: Schema.optional(Schema.String), url: Schema.String, source: Schema.optional(_FilePartSource), }) .annotate({ identifier: "FilePart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FilePart = Types.DeepMutable> export const AgentPart = Schema.Struct({ ...partBase, type: Schema.Literal("agent"), name: Schema.String, source: Schema.optional( Schema.Struct({ value: Schema.String, start: Schema.Number.check(Schema.isInt()), end: Schema.Number.check(Schema.isInt()), }), ), }) .annotate({ identifier: "AgentPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type AgentPart = Types.DeepMutable> export const CompactionPart = Schema.Struct({ ...partBase, type: Schema.Literal("compaction"), auto: Schema.Boolean, overflow: Schema.optional(Schema.Boolean), tail_start_id: Schema.optional(MessageID), }) .annotate({ identifier: "CompactionPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type CompactionPart = Types.DeepMutable> export const SubtaskPart = Schema.Struct({ ...partBase, type: Schema.Literal("subtask"), prompt: Schema.String, description: Schema.String, agent: Schema.String, model: Schema.optional( Schema.Struct({ providerID: ProviderID, modelID: ModelID, }), ), command: Schema.optional(Schema.String), }) .annotate({ identifier: "SubtaskPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SubtaskPart = Types.DeepMutable> export const RetryPart = Schema.Struct({ ...partBase, type: Schema.Literal("retry"), attempt: Schema.Number, // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate. error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }), time: Schema.Struct({ created: Schema.Number, }), }) .annotate({ identifier: "RetryPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type RetryPart = Omit>, "error"> & { error: APIError } export const StepStartPart = Schema.Struct({ ...partBase, type: Schema.Literal("step-start"), snapshot: Schema.optional(Schema.String), }) .annotate({ identifier: "StepStartPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type StepStartPart = Types.DeepMutable> export const StepFinishPart = Schema.Struct({ ...partBase, type: Schema.Literal("step-finish"), reason: Schema.String, snapshot: Schema.optional(Schema.String), cost: Schema.Number, tokens: Schema.Struct({ total: Schema.optional(Schema.Number), input: Schema.Number, output: Schema.Number, reasoning: Schema.Number, cache: Schema.Struct({ read: Schema.Number, write: Schema.Number, }), }), }) .annotate({ identifier: "StepFinishPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type StepFinishPart = Types.DeepMutable> export const ToolStatePending = Schema.Struct({ status: Schema.Literal("pending"), input: Schema.Record(Schema.String, Schema.Any), raw: Schema.String, }) .annotate({ identifier: "ToolStatePending" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStatePending = Types.DeepMutable> export const ToolStateRunning = Schema.Struct({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Any), title: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, }), }) .annotate({ identifier: "ToolStateRunning" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateRunning = Types.DeepMutable> export const ToolStateCompleted = Schema.Struct({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Any), output: Schema.String, title: Schema.String, metadata: Schema.Record(Schema.String, Schema.Any), time: Schema.Struct({ start: Schema.Number, end: Schema.Number, compacted: Schema.optional(Schema.Number), }), attachments: Schema.optional(Schema.Array(FilePart)), }) .annotate({ identifier: "ToolStateCompleted" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateCompleted = Types.DeepMutable> export const ToolStateError = Schema.Struct({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Any), error: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, end: Schema.Number, }), }) .annotate({ identifier: "ToolStateError" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateError = Types.DeepMutable> const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({ discriminator: "status", identifier: "ToolState", }) // Cast the derived zod so downstream z.infer sees the same mutable shape that // our exported TS types expose (the pre-migration Zod inferences were mutable). export const ToolState = Object.assign(_ToolState, { zod: zod(_ToolState) as unknown as z.ZodType< ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError >, }) export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError export const ToolPart = Schema.Struct({ ...partBase, type: Schema.Literal("tool"), callID: Schema.String, tool: Schema.String, state: _ToolState, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "ToolPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolPart = Omit>, "state"> & { state: ToolState } const Base = z.object({ id: MessageID.zod, sessionID: SessionID.zod, }) export const User = Base.extend({ role: z.literal("user"), time: z.object({ created: z.number(), }), format: Format.zod.optional(), summary: z .object({ title: z.string().optional(), body: z.string().optional(), diffs: Snapshot.FileDiff.zod.array(), }) .optional(), agent: z.string(), model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod, variant: z.string().optional(), }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), }).meta({ ref: "UserMessage", }) export type User = z.infer export type Part = | TextPart | SubtaskPart | ReasoningPart | FilePart | ToolPart | StepStartPart | StepFinishPart | SnapshotPart | PatchPart | AgentPart | RetryPart | CompactionPart // The derived `.zod` on each leaf is typed as `z.ZodType<...>`, but the walker // always emits a `z.ZodObject` at runtime. `z.discriminatedUnion` and // `z.infer` both rely on the ZodObject structural type, so cast here so the // resulting Part behaves like the pre-migration Zod union. export const Part = z .discriminatedUnion("type", [ TextPart.zod as unknown as z.ZodObject, SubtaskPart.zod as unknown as z.ZodObject, ReasoningPart.zod as unknown as z.ZodObject, FilePart.zod as unknown as z.ZodObject, ToolPart.zod as unknown as z.ZodObject, StepStartPart.zod as unknown as z.ZodObject, StepFinishPart.zod as unknown as z.ZodObject, SnapshotPart.zod as unknown as z.ZodObject, PatchPart.zod as unknown as z.ZodObject, AgentPart.zod as unknown as z.ZodObject, RetryPart.zod as unknown as z.ZodObject, CompactionPart.zod as unknown as z.ZodObject, ]) .meta({ ref: "Part", }) as unknown as z.ZodType // ── Prompt input schemas ───────────────────────────────────────────────────── // // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the // ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may // omit `id` to let the server allocate one. These Schema-Struct variants // carry that shape, and `SessionPrompt.PromptInput` just references the // derived `.zod` (no omit/partial gymnastics needed at the call site). export const TextPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("text"), text: Schema.String, synthetic: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "TextPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type TextPartInput = Types.DeepMutable> export const FilePartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("file"), mime: Schema.String, filename: Schema.optional(Schema.String), url: Schema.String, source: Schema.optional(_FilePartSource), }) .annotate({ identifier: "FilePartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FilePartInput = Types.DeepMutable> export const AgentPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("agent"), name: Schema.String, source: Schema.optional( Schema.Struct({ value: Schema.String, start: Schema.Number.check(Schema.isInt()), end: Schema.Number.check(Schema.isInt()), }), ), }) .annotate({ identifier: "AgentPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type AgentPartInput = Types.DeepMutable> export const SubtaskPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("subtask"), prompt: Schema.String, description: Schema.String, agent: Schema.String, model: Schema.optional( Schema.Struct({ providerID: ProviderID, modelID: ModelID, }), ), command: Schema.optional(Schema.String), }) .annotate({ identifier: "SubtaskPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SubtaskPartInput = Types.DeepMutable> export const Assistant = Base.extend({ role: z.literal("assistant"), time: z.object({ created: z.number(), completed: z.number().optional(), }), error: z .discriminatedUnion("name", [ AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, StructuredOutputError.Schema, ContextOverflowError.Schema, APIError.Schema, ]) .optional(), parentID: MessageID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, /** * @deprecated */ mode: z.string(), agent: z.string(), path: z.object({ cwd: z.string(), root: z.string(), }), summary: z.boolean().optional(), cost: z.number(), tokens: z.object({ total: z.number().optional(), input: z.number(), output: z.number(), reasoning: z.number(), cache: z.object({ read: z.number(), write: z.number(), }), }), structured: z.any().optional(), variant: z.string().optional(), finish: z.string().optional(), }).meta({ ref: "AssistantMessage", }) export type Assistant = z.infer export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ ref: "Message", }) export type Info = z.infer export const Event = { Updated: SyncEvent.define({ type: "message.updated", version: 1, aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, info: Info, }), }), Removed: SyncEvent.define({ type: "message.removed", version: 1, aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, }), }), PartUpdated: SyncEvent.define({ type: "message.part.updated", version: 1, aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, part: Part, time: z.number(), }), }), PartDelta: BusEvent.define( "message.part.delta", z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, field: z.string(), delta: z.string(), }), ), PartRemoved: SyncEvent.define({ type: "message.part.removed", version: 1, aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod, }), }), } export const WithParts = z.object({ info: Info, parts: z.array(Part), }) export type WithParts = { info: Info parts: Part[] } const Cursor = z.object({ id: MessageID.zod, time: z.number(), }) type Cursor = z.infer export const cursor = { encode(input: Cursor) { return Buffer.from(JSON.stringify(input)).toString("base64url") }, decode(input: string) { return Cursor.parse(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) }, } const info = (row: typeof MessageTable.$inferSelect) => ({ ...row.data, id: row.id, sessionID: row.session_id, }) as Info const part = (row: typeof PartTable.$inferSelect) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, }) as Part const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() if (ids.length > 0) { const partRows = Database.use((db) => db .select() .from(PartTable) .where(inArray(PartTable.message_id, ids)) .orderBy(PartTable.message_id, PartTable.id) .all(), ) for (const row of partRows) { const next = part(row) const list = partByMessage.get(row.message_id) if (list) list.push(next) else partByMessage.set(row.message_id, [next]) } } return rows.map((row) => ({ info: info(row), parts: partByMessage.get(row.id) ?? [], })) } function providerMeta(metadata: Record | undefined) { if (!metadata) return undefined const { providerExecuted: _, ...rest } = metadata return Object.keys(rest).length > 0 ? rest : undefined } export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, options?: { stripMedia?: boolean }, ) { const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages // for providers that don't support media in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need // to extract media and inject as user messages. Other SDKs (anthropic, google, // bedrock) handle type: "content" with media parts natively. // // Only apply this workaround if the model actually supports image input - // otherwise there's no point extracting images. const supportsMediaInToolResults = (() => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true if (model.api.npm === "@ai-sdk/amazon-bedrock") return true if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false })() const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output if (typeof output === "string") { return { type: "text", value: output } } if (typeof output === "object") { const outputObject = output as { text: string attachments?: Array<{ mime: string; url: string }> } const attachments = (outputObject.attachments ?? []).filter((attachment) => { return attachment.url.startsWith("data:") && attachment.url.includes(",") }) return { type: "content", value: [ { type: "text", text: outputObject.text }, ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, data: iife(() => { const commaIndex = attachment.url.indexOf(",") return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) }), })), ], } } return { type: "json", value: output as never } } for (const msg of input) { if (msg.parts.length === 0) continue if (msg.info.role === "user") { const userMessage: UIMessage = { id: msg.info.id, role: "user", parts: [], } result.push(userMessage) for (const part of msg.parts) { if (part.type === "text" && !part.ignored) userMessage.parts.push({ type: "text", text: part.text, }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (options?.stripMedia && isMedia(part.mime)) { userMessage.parts.push({ type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, }) } else { userMessage.parts.push({ type: "file", url: part.url, mediaType: part.mime, filename: part.filename, }) } } if (part.type === "compaction") { userMessage.parts.push({ type: "text", text: "What did we do so far?", }) } if (part.type === "subtask") { userMessage.parts.push({ type: "text", text: "The following tool was executed by the user", }) } } } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string }> = [] if ( msg.info.error && !( AbortedError.isInstance(msg.info.error) && msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") ) ) { continue } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", parts: [], } for (const part of msg.parts) { if (part.type === "text") assistantMessage.parts.push({ type: "text", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", }) if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) if (!supportsMediaInToolResults && mediaAttachments.length > 0) { media.push(...mediaAttachments) } const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments const output = finalAttachments.length > 0 ? { text: outputText, attachments: finalAttachments, } : outputText assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", toolCallId: part.callID, input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.state.status === "error") { const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined if (typeof output === "string") { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", toolCallId: part.callID, input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } else { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, input: part.state.input, errorText: part.state.error, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } } // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result if (part.state.status === "pending" || part.state.status === "running") assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } } if (assistantMessage.parts.length > 0) { result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results if (media.length > 0) { result.push({ id: MessageID.ascending(), role: "user", parts: [ { type: "text" as const, text: SYNTHETIC_ATTACHMENT_PROMPT, }, ...media.map((attachment) => ({ type: "file" as const, url: attachment.url, mediaType: attachment.mime, })), ], }) } } } } const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) return yield* Effect.promise(() => convertToModelMessages( result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) tools, }, ), ) }) export function toModelMessages( input: WithParts[], model: Provider.Model, options?: { stripMedia?: boolean }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } export function page(input: { sessionID: SessionID; limit: number; before?: string }) { const before = input.before ? cursor.decode(input.before) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) : eq(MessageTable.session_id, input.sessionID) const rows = Database.use((db) => db .select() .from(MessageTable) .where(where) .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) .limit(input.limit + 1) .all(), ) if (rows.length === 0) { const row = Database.use((db) => db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), ) if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], more: false, } } const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows const items = hydrate(slice) items.reverse() const tail = slice.at(-1) return { items, more, cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, } } export function* stream(sessionID: SessionID) { const size = 50 let before: string | undefined while (true) { const next = page({ sessionID, limit: size, before }) if (next.items.length === 0) break for (let i = next.items.length - 1; i >= 0; i--) { yield next.items[i] } if (!next.more || !next.cursor) break before = next.cursor } } export function parts(message_id: MessageID) { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) return rows.map( (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, }) as Part, ) } export function get(input: { sessionID: SessionID; messageID: MessageID }): WithParts { const row = Database.use((db) => db .select() .from(MessageTable) .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) .get(), ) if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` }) return { info: info(row), parts: parts(input.messageID), } } export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) if (retain) { if (msg.info.id === retain) break continue } if (msg.info.role === "user" && completed.has(msg.info.id)) { const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") if (!part) continue if (!part.tail_start_id) break retain = part.tail_start_id if (msg.info.id === retain) break continue } if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() return result } export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) { return filterCompacted(stream(sessionID)) }) export function fromError( e: unknown, ctx: { providerID: ProviderID; aborted?: boolean }, ): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": return new AbortedError( { message: e.message }, { cause: e, }, ).toObject() case OutputLengthError.isInstance(e): return e case LoadAPIKeyError.isInstance(e): return new AuthError( { providerID: ctx.providerID, message: e.message, }, { cause: e }, ).toObject() case (e as SystemError)?.code === "ECONNRESET": return new APIError( { message: "Connection reset by server", isRetryable: true, metadata: { code: (e as SystemError).code ?? "", syscall: (e as SystemError).syscall ?? "", message: (e as SystemError).message ?? "", }, }, { cause: e }, ).toObject() case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError": if (ctx.aborted) { return new AbortedError({ message: e.message }, { cause: e }).toObject() } return new APIError( { message: "Response decompression failed", isRetryable: true, metadata: { code: (e as FetchDecompressionError).code, message: e.message, }, }, { cause: e }, ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, error: e, }) if (parsed.type === "context_overflow") { return new ContextOverflowError( { message: parsed.message, responseBody: parsed.responseBody, }, { cause: e }, ).toObject() } return new APIError( { message: parsed.message, statusCode: parsed.statusCode, isRetryable: parsed.isRetryable, responseHeaders: parsed.responseHeaders, responseBody: parsed.responseBody, metadata: parsed.metadata, }, { cause: e }, ).toObject() case e instanceof Error: return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() default: try { const parsed = ProviderError.parseStreamError(e) if (parsed) { if (parsed.type === "context_overflow") { return new ContextOverflowError( { message: parsed.message, responseBody: parsed.responseBody, }, { cause: e }, ).toObject() } return new APIError( { message: parsed.message, isRetryable: parsed.isRetryable, responseBody: parsed.responseBody, }, { cause: e, }, ).toObject() } } catch {} return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() } } export * as MessageV2 from "./message-v2"