import type { Sql } from '../../db.js'; import type { ToolCall, ToolResult } from '../../types/api.js'; // v1.13.0: dual-write helper. Every site that writes the legacy // messages.tool_calls / messages.tool_results JSON columns calls into here // to mirror the same data into message_parts rows. Reads still go to the // JSON columns; the swap to parts-as-source-of-truth happens in a later // v1.13 dispatch alongside the AI SDK streamText migration. export type PartKind = 'text' | 'tool_call' | 'tool_result' | 'reasoning' | 'step_start'; export interface PartInsert { message_id: string; sequence: number; kind: PartKind; payload: unknown; } export async function insertParts(sql: Sql, parts: PartInsert[]): Promise { if (parts.length === 0) return; // postgres-js fans out an array of objects to a multi-row INSERT. Each // payload field needs sql.json() so jsonb storage receives a JSON value // rather than a quoted string. await sql` INSERT INTO message_parts ${sql( parts.map((p) => ({ message_id: p.message_id, sequence: p.sequence, kind: p.kind, payload: sql.json(p.payload as never), })), 'message_id', 'sequence', 'kind', 'payload', )} `; } // Derive parts from the canonical messages row for an assistant message. // reasoning (when non-empty) becomes a 'reasoning' part at sequence 0 — // it precedes user-visible content logically. content (when non-empty) // becomes a 'text' part next; each tool_call becomes a 'tool_call' part // with payload { id, name, args } where args is the parsed object (we // use the in-memory ToolCall shape, not the OpenAI stringified one). export function partsFromAssistantMessage(args: { content: string; tool_calls: ToolCall[] | null; // v1.13.1-C: optional reasoning text streamed alongside the answer. // Most rows have none — only models with separate reasoning channels // (qwen3.6 etc.) populate this. reasoning?: string; }): Omit[] { const out: Omit[] = []; let seq = 0; if (args.reasoning && args.reasoning.length > 0) { out.push({ sequence: seq, kind: 'reasoning', payload: { text: args.reasoning } }); seq += 1; } if (args.content && args.content.length > 0) { out.push({ sequence: seq, kind: 'text', payload: { text: args.content } }); seq += 1; } for (const tc of args.tool_calls ?? []) { out.push({ sequence: seq, kind: 'tool_call', payload: { id: tc.id, name: tc.name, args: tc.args }, }); seq += 1; } return out; } // Derive a single tool_result part from a tool message's tool_results JSON. // The payload includes the same shape that buildMessagesPayload reads from // later: tool_call_id, output, optional error/truncated metadata. export function partsFromToolMessage(args: { tool_results: ToolResult | null; }): Omit[] { if (!args.tool_results) return []; const tr = args.tool_results; return [ { sequence: 0, kind: 'tool_result', payload: { tool_call_id: tr.tool_call_id, output: tr.output, truncated: tr.truncated, ...(tr.error ? { error: tr.error } : {}), }, }, ]; }