import type { ModelMessage } from 'ai'; import { and, eq, isNull, lte } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../../connection'; import { messages } from '../../schema'; /** * Convert messages from old CoreMessage format (v4) to ModelMessage format (v5) * Key changes: * - Tool calls: 'args' → 'input' * - Tool results: 'result' → structured 'output' object * - Image/File parts: 'mimeType' → 'mediaType' * - User/Assistant string content remains as string (v5 supports both) */ export function convertCoreToModel(messages: unknown): ModelMessage[] { if (!Array.isArray(messages)) { return []; } return messages.map((message: unknown) => { // Basic validation if (!message || typeof message !== 'object' || !('role' in message)) { return message as ModelMessage; } const msg = message as Record; const { role, content } = msg; switch (role) { case 'system': // System messages remain string-based in both v4 and v5 return { ...msg, content: typeof content === 'string' ? content : '', } as ModelMessage; case 'user': // User messages: handle both string and array content if (typeof content === 'string') { // v5 supports string content directly for user messages return msg as ModelMessage; } if (Array.isArray(content)) { // Convert any image/file parts return { ...msg, content: content.map(convertContentPart), } as ModelMessage; } return { ...msg, content: '' } as ModelMessage; case 'assistant': // Assistant messages: handle both string and array content if (typeof content === 'string') { // v5 supports string content directly for assistant messages return msg as ModelMessage; } if (Array.isArray(content)) { // Convert tool calls and other parts return { ...msg, content: content.map(convertContentPart), } as ModelMessage; } return { ...msg, content: '' } as ModelMessage; case 'tool': // Tool messages: convert result to structured output if (Array.isArray(content)) { // Convert and flatten nested results const convertedContent = []; for (const part of content) { const converted = convertToolResultPart(part); if (converted !== null) { if (Array.isArray(converted)) { // Flatten nested arrays (from wrapper tool results) convertedContent.push(...converted); } else { convertedContent.push(converted); } } } return { ...msg, content: convertedContent, } as ModelMessage; } return { role: 'tool', content: [] } as ModelMessage; default: return msg as ModelMessage; } }); } /** * Convert content parts from v4 to v5 format */ function convertContentPart(part: unknown): unknown { if (!part || typeof part !== 'object') { return part; } const p = part as Record; switch (p.type) { case 'text': // Text parts remain the same return part; case 'image': // Convert mimeType → mediaType if ('mimeType' in p) { const { mimeType, ...rest } = p; return { ...rest, mediaType: mimeType, }; } return part; case 'file': // Convert mimeType → mediaType if ('mimeType' in p) { const { mimeType, ...rest } = p; return { ...rest, mediaType: mimeType, }; } return part; case 'tool-call': // Tool calls: args → input (AI SDK v5 change) if ('args' in p) { const { args, ...rest } = p; return { ...rest, input: args }; } return part; default: return part; } } /** * Convert tool result parts from v4 to v5 format */ function convertToolResultPart(part: unknown): unknown | unknown[] | null { if (!part || typeof part !== 'object') { return part; } const p = part as Record; if (p.type !== 'tool-result') { return part; } // Check if this is a wrapper with empty toolCallId/toolName and nested results if ( 'toolCallId' in p && (p.toolCallId === '' || !p.toolCallId) && 'toolName' in p && (p.toolName === '' || !p.toolName) && 'result' in p && Array.isArray(p.result) ) { // This is a wrapper - extract and convert the nested tool results const nestedResults = []; for (const nestedItem of p.result) { if ( nestedItem && typeof nestedItem === 'object' && 'type' in nestedItem && nestedItem.type === 'tool-result' ) { const converted = convertToolResultPart(nestedItem); if (converted !== null) { if (Array.isArray(converted)) { nestedResults.push(...converted); } else { nestedResults.push(converted); } } } } // Return the array of nested results to be flattened return nestedResults.length > 0 ? nestedResults : null; } // Only convert if we have 'result' field but not 'output' (v4 format) if ('result' in p && !('output' in p)) { const { result, isError, ...rest } = p; // Validate toolCallId exists and matches Anthropic's pattern const toolCallId = rest.toolCallId; if (!toolCallId || typeof toolCallId !== 'string' || toolCallId.trim() === '') { console.warn('[chatConversationHistory] Skipping tool-result with invalid toolCallId:', { toolCallId, toolName: rest.toolName, }); // Skip this tool result entirely if toolCallId is invalid return null; } // Check if toolCallId matches the required pattern const validPattern = /^[a-zA-Z0-9_-]+$/; if (!validPattern.test(toolCallId)) { console.warn('[chatConversationHistory] Tool-result has invalid toolCallId format:', { toolCallId, toolName: rest.toolName, }); // Skip this tool result if the ID doesn't match the pattern return null; } // Convert to v5's structured output format let output: { type: string; value: unknown }; if (isError) { // Error results if (typeof result === 'string') { output = { type: 'error-text', value: result }; } else { output = { type: 'error-json', value: result }; } } else { // Success results if (typeof result === 'string') { output = { type: 'text', value: result }; } else { output = { type: 'json', value: result }; } } return { ...rest, output, }; } // Already in v5 format or doesn't need conversion return part; } // Helper function to get chatId and createdAt from messageId async function getMessageInfo(messageId: string): Promise<{ chatId: string; createdAt: string }> { let messageResult: Array<{ chatId: string; createdAt: string }>; try { messageResult = await db .select({ chatId: messages.chatId, createdAt: messages.createdAt, }) .from(messages) .where(and(eq(messages.id, messageId), isNull(messages.deletedAt))) .limit(1); } catch (dbError) { throw new Error( `Database query failed while finding message: ${dbError instanceof Error ? dbError.message : 'Unknown database error'}` ); } const messageRow = messageResult[0]; if (!messageRow) { throw new Error('Message not found or has been deleted'); } return messageRow; } // Helper function to get messages for a chat up to a specific timestamp async function getMessagesUpToTimestamp( chatId: string, upToTimestamp: string ): Promise< Array<{ id: string; rawLlmMessages: unknown; createdAt: string; isCompleted: boolean; }> > { let chatMessages: Array<{ id: string; rawLlmMessages: unknown; createdAt: string; isCompleted: boolean; }>; try { chatMessages = await db .select({ id: messages.id, rawLlmMessages: messages.rawLlmMessages, createdAt: messages.createdAt, isCompleted: messages.isCompleted, }) .from(messages) .where( and( eq(messages.chatId, chatId), isNull(messages.deletedAt), lte(messages.createdAt, upToTimestamp) ) ) .orderBy(messages.createdAt); } catch (dbError) { throw new Error( `Database query failed while loading chat messages: ${dbError instanceof Error ? dbError.message : 'Unknown database error'}` ); } return chatMessages; } // Zod schemas for validation export const ChatConversationHistoryInputSchema = z.object({ messageId: z.string().uuid('Message ID must be a valid UUID'), }); export const ChatConversationHistoryOutputSchema = z.array(z.custom()); export type ChatConversationHistoryInput = z.infer; export type ChatConversationHistoryOutput = z.infer; /** * Removes orphaned assistant messages with tool calls that have no matching tool results * An orphaned message is an assistant message where ALL its tool calls lack corresponding tool results */ function removeOrphanedToolCalls(messages: ModelMessage[]): ModelMessage[] { // Build a Set of all tool call IDs that have results (from tool role messages) const toolCallIdsWithResults = new Set(); for (const message of messages) { if (message.role === 'tool' && Array.isArray(message.content)) { for (const part of message.content) { if ( typeof part === 'object' && part !== null && 'type' in part && part.type === 'tool-result' && 'toolCallId' in part && typeof part.toolCallId === 'string' ) { toolCallIdsWithResults.add(part.toolCallId); } } } } // Filter out assistant messages where ALL tool calls are orphaned const filteredMessages: ModelMessage[] = []; for (const message of messages) { // Only check assistant messages with array content if (message.role === 'assistant' && Array.isArray(message.content)) { // Extract tool call IDs from this message const toolCallIds: string[] = []; for (const part of message.content) { if ( typeof part === 'object' && part !== null && 'type' in part && part.type === 'tool-call' && 'toolCallId' in part && typeof part.toolCallId === 'string' ) { toolCallIds.push(part.toolCallId); } } // If this message has no tool calls, keep it if (toolCallIds.length === 0) { filteredMessages.push(message); continue; } // Check if ANY of the tool calls have results const hasAnyResults = toolCallIds.some((id) => toolCallIdsWithResults.has(id)); if (hasAnyResults) { // At least one tool call has a result, keep the message filteredMessages.push(message); } else { // ALL tool calls are orphaned, skip this message entirely console.warn('[chatConversationHistory] Removing orphaned assistant message:', { orphanedToolCallIds: toolCallIds, }); } } else { // Keep all non-assistant messages (including tool role messages) filteredMessages.push(message); } } return filteredMessages; } /** * Get conversation history for a chat up to and including a specific message * Finds the chat from the given messageId, then merges and deduplicates all rawLlmMessages * from messages up to and including the specified message to create the conversation history */ export async function getChatConversationHistory( input: ChatConversationHistoryInput ): Promise { try { // Validate input const validatedInput = ChatConversationHistoryInputSchema.parse(input); // Get chatId and timestamp from messageId const { chatId, createdAt } = await getMessageInfo(validatedInput.messageId); // Get all messages for this chat up to and including the current message const chatMessages = await getMessagesUpToTimestamp(chatId, createdAt); // Collect all rawLlmMessages from all messages const allRawMessages: unknown[] = []; for (const message of chatMessages) { if (message.rawLlmMessages && Array.isArray(message.rawLlmMessages)) { allRawMessages.push(...message.rawLlmMessages); } } if (allRawMessages.length === 0) { // If no messages with LLM data, return empty array return []; } // Convert from old CoreMessage format to new ModelMessage format if needed const convertedMessages = convertCoreToModel(allRawMessages); // Deduplicate messages based on content and role // We'll use a Map to track unique messages, using a combination of role and stringified content as the key const uniqueMessagesMap = new Map(); for (const message of convertedMessages) { // Create a unique key based on role and content // This ensures we don't have duplicate messages with the same role and content const messageKey = JSON.stringify({ role: message.role, content: message.content, // Include experimental_providerMetadata if it has messageId for better deduplication messageId: 'experimental_providerMetadata' in message && message.experimental_providerMetadata && typeof message.experimental_providerMetadata === 'object' && 'messageId' in message.experimental_providerMetadata ? message.experimental_providerMetadata.messageId : undefined, }); // Only add if we haven't seen this message before if (!uniqueMessagesMap.has(messageKey)) { uniqueMessagesMap.set(messageKey, message); } } // Convert back to array and maintain chronological order // Since we're merging from multiple messages, we should preserve the order they appear const deduplicatedMessages = Array.from(uniqueMessagesMap.values()); // Remove orphaned tool calls (tool calls without matching tool results) const cleanedMessages = removeOrphanedToolCalls(deduplicatedMessages); // Validate output try { return ChatConversationHistoryOutputSchema.parse(cleanedMessages); } catch (validationError) { throw new Error( `Output validation failed: ${validationError instanceof Error ? validationError.message : 'Invalid output format'}` ); } } catch (error) { // Handle Zod input validation errors if (error instanceof z.ZodError) { throw new Error(`Invalid input: ${error.errors.map((e) => e.message).join(', ')}`); } // Re-throw other errors with context throw error instanceof Error ? error : new Error(`Failed to get chat conversation history: ${String(error)}`); } }