| 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<Message, "id" | "time">, b: Pick<Message, "id" | "time">) { |
| const left = messageKey(a) |
| const right = messageKey(b) |
| return left < right ? -1 : left > right ? 1 : 0 |
| } |
|
|
| export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id |
|
|
| function record(value: unknown): value is Record<string, unknown> { |
| return !!value && typeof value === "object" && !Array.isArray(value) |
| } |
|
|
| function normalizeToolInput(name: string, input: Record<string, unknown>) { |
| 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<string, unknown>) { |
| 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<string, Part[]>() |
| 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.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.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.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 }, |
| } |
| } |
|
|