import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo, SessionMessageShell, SessionMessageUser, } from "@opencode-ai/client/promise" import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" import { Option, Schema } from "effect" const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) export function compareMessages(a: Pick, b: Pick) { const left = messageKey(a) const right = messageKey(b) return left < right ? -1 : left > right ? 1 : 0 } export const messageKey = (message: Pick) => message.time.created + message.id function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } function normalizeToolInput(name: string, input: Record) { if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") return input return { ...input, filePath: input.path } } function normalizeToolMetadata(name: string, metadata: Record) { if (name !== "edit" || !Array.isArray(metadata.files)) return metadata const file = metadata.files.find(record) if (!file || typeof file.file !== "string") return metadata return { ...metadata, filediff: { file: file.file, patch: typeof file.patch === "string" ? file.patch : undefined, additions: typeof file.additions === "number" ? file.additions : 0, deletions: typeof file.deletions === "number" ? file.deletions : 0, }, } } export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { const messages: Message[] = [] const parts = new Map() let agent = "" let model = emptyModel let parentID: string | undefined source.forEach((message) => { if (message.type === "agent-switched") { agent = message.agent return } if (message.type === "model-switched") { model = message.model return } if (message.type === "user") { parentID = message.id messages.push(userMessage(sessionID, message, agent, model)) parts.set(message.id, userParts(sessionID, message)) return } if (message.type === "synthetic" && message.description?.trim()) { parentID = message.id messages.push({ id: message.id, sessionID, role: "user", time: message.time, agent, model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, }) parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) return } if (message.type === "shell") { messages.push(...shellMessages(sessionID, message, agent, model)) parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) parentID = undefined return } if (message.type === "assistant") { agent = message.agent model = message.model if (!parentID) return const parent = messages.findLast((item) => item.id === parentID) if (parent?.role === "user") { parent.agent = message.agent parent.model = { providerID: message.model.providerID, modelID: message.model.id, variant: message.model.variant, } } messages.push(assistantMessage(sessionID, parentID, message)) parts.set(message.id, assistantParts(sessionID, message)) return } if (message.type !== "compaction" || !parentID) return parts.set(parentID, [ ...(parts.get(parentID) ?? []), { id: `${message.id}:compaction`, sessionID, messageID: parentID, type: "compaction", auto: message.reason === "auto", }, ]) }) return { messages, parts } } function shellMessages( sessionID: string, message: SessionMessageShell, agent: string, model: { id: string; providerID: string; variant?: string }, ): [UserMessage, AssistantMessage] { return [ { id: message.id, sessionID, role: "user", time: { created: message.time.created }, agent, model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, }, { id: `${message.id}:assistant`, sessionID, role: "assistant", time: message.time, parentID: message.id, modelID: model.id, providerID: model.providerID, variant: model.variant, mode: agent, agent, path: { cwd: "", root: "" }, cost: 0, tokens: emptyTokens, }, ] } function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { const input = { command: message.command } const start = message.time.created const state: ToolPart["state"] = message.status === "running" ? { status: "running", input, time: { start } } : { status: "completed", input, output: message.output?.output ?? "", title: "Shell", metadata: { status: message.status, exit: message.exit, truncated: message.output?.truncated, }, time: { start, end: message.time.completed ?? start }, } return { id: `${message.id}:tool`, sessionID, messageID: `${message.id}:assistant`, type: "tool", callID: message.shellID, tool: "bash", state, } } export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { return `${messageID}:${type}:${ordinal}` } function userMessage( sessionID: string, message: SessionMessageUser, agent: string, model: { id: string; providerID: string; variant?: string }, ): UserMessage { return { id: message.id, sessionID, role: "user", time: message.time, agent, model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, } } function userParts(sessionID: string, message: SessionMessageUser): Part[] { return [ textPart(sessionID, message.id, 0, message.text), ...(message.files ?? []).map( (file, index): FilePart => ({ id: `${message.id}:file:${index}`, sessionID, messageID: message.id, type: "file", mime: file.mime, filename: file.name, url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, source: file.mention ? { type: "file", text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), } : undefined, }), ), ...(message.agents ?? []).map( (item, index): Part => ({ id: `${message.id}:agent:${index}`, sessionID, messageID: message.id, type: "agent", name: item.name, source: item.mention ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } : undefined, }), ), ] } function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { const error = message.error ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } : { name: "UnknownError" as const, data: { message: message.error.message } } : undefined return { id: message.id, sessionID, role: "assistant", time: message.time, error, parentID, modelID: message.model.id, providerID: message.model.providerID, variant: message.model.variant, mode: message.agent, agent: message.agent, path: { cwd: "", root: "" }, cost: message.cost ?? 0, tokens: message.tokens ?? emptyTokens, finish: message.finish, } } function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { const ordinals = { text: 0, reasoning: 0 } return message.content.flatMap((content): Part[] => { if (content.type === "text") { const part = textPart(sessionID, message.id, ordinals.text++, content.text) return content.text.trim() ? [part] : [] } if (content.type === "reasoning") { const part: Part = { id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), sessionID, messageID: message.id, type: "reasoning", text: content.text, metadata: content.state, time: { start: content.time?.created ?? message.time.created, end: content.time?.completed, }, } return content.text.trim() ? [part] : [] } return [toolPart(sessionID, message.id, content)] }) } function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { return { id: sessionMessagePartID(messageID, "text", ordinal), sessionID, messageID, type: "text", text, synthetic, } } function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { const start = tool.time.ran ?? tool.time.created const state = (() => { if (tool.state.status === "streaming") { const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) const input = normalizeToolInput(tool.name, record(value) ? value : {}) return { status: "pending" as const, input, raw: tool.state.input } } if (tool.state.status === "running") { return { status: "running" as const, input: normalizeToolInput(tool.name, tool.state.input), // metadata: normalizeToolMetadata(tool.name, tool.state.structured), metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start }, } } if (tool.state.status === "error") { return { status: "error" as const, input: normalizeToolInput(tool.name, tool.state.input), error: tool.state.error.message, // metadata: normalizeToolMetadata(tool.name, tool.state.structured), metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start, end: tool.time.completed ?? start }, } } const attachments = tool.state.content.flatMap((item, index): FilePart[] => item.type === "file" ? [ { id: `${tool.id}:file:${index}`, sessionID, messageID, type: "file", mime: item.mime, filename: item.name, url: item.uri, }, ] : [], ) return { status: "completed" as const, input: normalizeToolInput(tool.name, tool.state.input), output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), title: tool.name, // metadata: normalizeToolMetadata(tool.name, tool.state.structured), metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start, end: tool.time.completed ?? start }, attachments: attachments.length ? attachments : undefined, } })() return { id: tool.id, sessionID, messageID, type: "tool", callID: tool.id, tool: tool.name, state, metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, } }