import { z } from 'zod'; import { ConversationMessageSchema } from './conversationValidators.js'; /** * Shared validation schemas for Second Opinion Agent responses * * CRITICAL: These validators enforce the contract that the frontend depends on. * Second opinion responses must include: * - Primary model response * - Secondary model opinions * - Synthesis combining all insights * - Assistant message with metadata * * Usage: * - Backend: Validate responses before returning to MCP clients * - Frontend: Validate responses after parsing from API * - Tests: Assert on response structure */ // Model opinion schema export const ModelOpinionSchema = z.object({ model: z.string().min(1, 'Model name must not be empty'), response: z.string(), tokensUsed: z.number().int().nonnegative().optional(), latencyMs: z.number().nonnegative().optional(), citations: z.array(z.object({ title: z.string(), url: z.string().url(), snippet: z.string().optional(), })).optional(), }).passthrough(); // Synthesis schema - matches actual SecondOpinionAgent output structure export const SynthesisSchema = z.object({ model: z.string().min(1, 'Synthesis must include model name'), response: z.string().min(1, 'Synthesis must include response text'), modelId: z.string().optional(), modelDisplayName: z.string().optional(), tokensUsed: z.number().int().nonnegative().optional(), latencyMs: z.number().nonnegative().optional(), cost: z.number().optional(), sources: z.array(z.any()).optional(), error: z.boolean().optional(), }).passthrough(); // Conversation persistence status schema export const ConversationPersistenceStatusSchema = z.object({ userMessageSaved: z.boolean(), assistantMessageSaved: z.boolean(), userMessageId: z.string().nullable(), assistantMessageId: z.string().nullable(), error: z.string().nullable().optional(), }).passthrough(); // Second opinion result schema export const SecondOpinionResultSchema = z.object({ // Core response data primary: ModelOpinionSchema, secondaryOpinions: z.array(ModelOpinionSchema).optional(), synthesis: SynthesisSchema.nullable(), // Can be null when maxOpinions=0 // Conversation tracking conversationId: z.string().min(1, 'conversationId must not be empty'), conversationCreated: z.boolean().optional(), conversationTitle: z.string().nullable().optional(), // Message persistence userMessageId: z.string().nullable().optional(), assistantMessageId: z.string().nullable().optional(), persistenceStatus: ConversationPersistenceStatusSchema.optional(), // Assistant message with metadata assistantMessage: ConversationMessageSchema.optional(), // Metadata (should include secondOpinion data) metadata: z.object({ secondOpinion: z.object({ primary: ModelOpinionSchema, secondaryOpinions: z.array(ModelOpinionSchema).optional(), synthesis: SynthesisSchema, }).optional(), persistenceStatus: ConversationPersistenceStatusSchema.optional(), }).passthrough().optional(), // Latency tracking latency: z.object({ totalMs: z.number().nonnegative(), primary: z.number().nonnegative().optional(), secondary: z.number().nonnegative().optional(), synthesis: z.number().nonnegative().optional(), }).passthrough().optional(), // Thinking log for debugging thinkingLog: z.array(z.object({ timestamp: z.number(), thought: z.string(), type: z.enum(['start', 'primary', 'secondary', 'synthesis', 'complete', 'error']).optional(), }).passthrough()).optional(), }).passthrough(); /** * Validate agent.second_opinion response * * CRITICAL: This will FAIL if the response is missing required fields or * if metadata is not properly structured. This is intentional - it should * drive fixing the backend to return complete, properly structured responses. */ export function validateSecondOpinionResponse(response: unknown): z.infer { try { return SecondOpinionResultSchema.parse(response); } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map(e => { const path = e.path.join('.'); return `${path}: ${e.message}`; }).join(', '); throw new Error(`agent.second_opinion response validation failed: ${errors}`); } throw error; } } /** * Validate that assistant message metadata includes second opinion data * * This is critical for the frontend to display second opinion details after * retrieving the message from conversation history. */ export function validateAssistantMetadata(message: unknown): void { const MessageWithMetadataSchema = z.object({ metadata: z.object({ secondOpinion: z.object({ primary: ModelOpinionSchema, synthesis: SynthesisSchema, }).passthrough(), }).passthrough(), }).passthrough(); try { MessageWithMetadataSchema.parse(message); } catch (error) { if (error instanceof z.ZodError) { throw new Error( `Assistant message metadata validation failed: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}` ); } throw error; } } /** * Type exports for TypeScript consumers */ export type ModelOpinion = z.infer; export type Synthesis = z.infer; export type ConversationPersistenceStatus = z.infer; export type SecondOpinionResult = z.infer;