// v1.13.11-a: Zod schemas for every WebSocket frame published by the server. // Validation runs both on send (broker.publishFrame / publishUserFrame) and // on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches // silent protocol drift between publisher and consumer. // // IMPORTANT: This file is duplicated byte-identical at // apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and // no path alias; the duplication is sync-by-hand. A test asserts the two // files match. If you change one, change the other. // // Per-kind payload schemas (tool_call args, message_parts payloads, etc.) // stay z.unknown() in v1.13.11. Frame-level drift detection is the goal; // deep payload validation is follow-up work. import { z } from 'zod'; // ---- shared primitives ----------------------------------------------------- const Uuid = z.string().uuid(); // Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs. const ToolCallId = z.string().min(1); // v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not // strings. The publish sites pass them through unchanged, so the schema must // tolerate both. preprocess converts Date → ISO string before string-validation; // on the web side (where frames arrive via JSON.parse) it's a no-op. Before // this fix, every message_complete / session_updated / chat_updated frame // failed validation and got dropped — symptoms: token tracking blank in UI, // status stuck at 'streaming' tripping the 60s stale-stream banner. const IsoTimestamp = z.preprocess( (v) => (v instanceof Date ? v.toISOString() : v), z.string().min(1), ); const ChatStatusValue = z.enum([ 'streaming', 'tool_running', 'waiting_for_input', 'idle', 'error', ]); const ErrorReasonValue = z.enum([ 'llm_provider_error', 'doom_loop', 'doom_loop_summary_failed', 'cap_hit', 'cap_hit_summary_failed', ]); const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']); const ToolCallShape = z.object({ id: ToolCallId, name: z.string().min(1), args: z.record(z.string(), z.unknown()), }); // Free-form bags: opaque to the frame schema; deep validation is out of // scope for v1.13.11 (frame-level drift detection is the goal; per-kind // payload narrowing is follow-up work). z.unknown() means the consumer // must narrow before reading — TypeScript-side this is fine because every // consumer already operates on the hand-maintained Project / Chat / Session // / WorkspacePane types (the brief's "Don't strip existing types yet" // rule), and the Zod-typed shape is only used at the publishFrame boundary. const OpaqueObject = z.unknown(); // ---- per-session channel frames -------------------------------------------- export const SnapshotFrame = z.object({ type: z.literal('snapshot'), messages: z.array(OpaqueObject), }); export const MessageStartedFrame = z.object({ type: z.literal('message_started'), message_id: Uuid, chat_id: Uuid.optional(), role: MessageRoleValue, }); export const DeltaFrame = z.object({ type: z.literal('delta'), message_id: Uuid, chat_id: Uuid.optional(), content: z.string(), }); export const ReasoningDeltaFrame = z.object({ type: z.literal('reasoning_delta'), message_id: Uuid, chat_id: Uuid.optional(), content: z.string(), }); export const ToolCallFrame = z.object({ type: z.literal('tool_call'), message_id: Uuid, chat_id: Uuid.optional(), tool_call: ToolCallShape, }); export const ToolResultFrame = z.object({ type: z.literal('tool_result'), tool_message_id: Uuid, chat_id: Uuid.optional(), tool_call_id: ToolCallId, output: z.unknown(), truncated: z.boolean(), error: z.string().optional(), }); export const MessageCompleteFrame = z.object({ type: z.literal('message_complete'), message_id: Uuid, chat_id: Uuid.optional(), tokens_used: z.number().int().nonnegative().nullable().optional(), ctx_used: z.number().int().nonnegative().nullable().optional(), ctx_max: z.number().int().positive().nullable().optional(), started_at: IsoTimestamp.nullable().optional(), finished_at: IsoTimestamp.nullable().optional(), model: z.string().optional(), metadata: OpaqueObject.nullable().optional(), }); export const UsageFrame = z.object({ type: z.literal('usage'), message_id: Uuid, chat_id: Uuid.optional(), completion_tokens: z.number().int().nonnegative().nullable(), ctx_used: z.number().int().nonnegative().nullable(), ctx_max: z.number().int().positive().nullable(), }); export const MessagesDeletedFrame = z.object({ type: z.literal('messages_deleted'), message_ids: z.array(Uuid), chat_id: Uuid.optional(), }); export const ChatRenamedFrame = z.object({ type: z.literal('chat_renamed'), chat_id: Uuid, name: z.string(), }); export const CompactedFrame = z.object({ type: z.literal('compacted'), session_id: Uuid, chat_id: Uuid, summary_message_id: Uuid, }); export const ErrorFrame = z.object({ type: z.literal('error'), message_id: Uuid.optional(), chat_id: Uuid.optional(), error: z.string(), reason: ErrorReasonValue.optional(), }); // ---- per-user channel frames (sidebar refresh) ----------------------------- export const ChatStatusFrame = z.object({ type: z.literal('chat_status'), chat_id: Uuid, status: ChatStatusValue, at: IsoTimestamp, reason: ErrorReasonValue.optional(), }); export const SessionUpdatedFrame = z.object({ type: z.literal('session_updated'), session_id: Uuid, project_id: Uuid, name: z.string(), updated_at: IsoTimestamp, }); export const SessionRenamedFrame = z.object({ type: z.literal('session_renamed'), session_id: Uuid, name: z.string(), }); export const SessionCreatedFrame = z.object({ type: z.literal('session_created'), session: OpaqueObject, project_id: Uuid, }); export const SessionArchivedFrame = z.object({ type: z.literal('session_archived'), session_id: Uuid, project_id: Uuid, }); export const SessionDeletedFrame = z.object({ type: z.literal('session_deleted'), session_id: Uuid, project_id: Uuid, }); export const SessionWorkspaceUpdatedFrame = z.object({ type: z.literal('session_workspace_updated'), session_id: Uuid, workspace_panes: z.array(OpaqueObject), }); export const ChatCreatedFrame = z.object({ type: z.literal('chat_created'), chat: OpaqueObject, session_id: Uuid, }); export const ChatUpdatedFrame = z.object({ type: z.literal('chat_updated'), chat_id: Uuid, session_id: Uuid, name: z.string().nullable(), updated_at: IsoTimestamp, }); export const ChatArchivedFrame = z.object({ type: z.literal('chat_archived'), chat_id: Uuid, session_id: Uuid, }); export const ChatUnarchivedFrame = z.object({ type: z.literal('chat_unarchived'), chat: OpaqueObject, }); export const ChatDeletedFrame = z.object({ type: z.literal('chat_deleted'), chat_id: Uuid, session_id: Uuid, }); export const ProjectCreatedFrame = z.object({ type: z.literal('project_created'), project: OpaqueObject, }); export const ProjectArchivedFrame = z.object({ type: z.literal('project_archived'), project_id: Uuid, }); export const ProjectUnarchivedFrame = z.object({ type: z.literal('project_unarchived'), project: OpaqueObject, }); export const ProjectUpdatedFrame = z.object({ type: z.literal('project_updated'), project_id: Uuid, name: z.string(), }); export const ProjectDeletedFrame = z.object({ type: z.literal('project_deleted'), project_id: Uuid, }); const PermissionOptionShape = z.object({ option_id: z.string(), label: z.string(), }); export const PermissionRequestedFrame = z.object({ type: z.literal('permission_requested'), task_id: Uuid, session_id: Uuid, kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(), tool_title: z.string().optional(), input: z.record(z.unknown()).optional(), options: z.array(PermissionOptionShape), }); export const PermissionResolvedFrame = z.object({ type: z.literal('permission_resolved'), task_id: Uuid, session_id: Uuid, }); const AgentCommandShape = z.object({ name: z.string(), description: z.string().optional(), }); export const AgentCommandsFrame = z.object({ type: z.literal('agent_commands'), task_id: Uuid, session_id: Uuid, commands: z.array(AgentCommandShape), }); // ---- discriminated union --------------------------------------------------- export const WsFrameSchema = z.discriminatedUnion('type', [ // per-session SnapshotFrame, MessageStartedFrame, DeltaFrame, ReasoningDeltaFrame, ToolCallFrame, ToolResultFrame, MessageCompleteFrame, UsageFrame, MessagesDeletedFrame, ChatRenamedFrame, CompactedFrame, ErrorFrame, PermissionRequestedFrame, PermissionResolvedFrame, AgentCommandsFrame, // per-user ChatStatusFrame, SessionUpdatedFrame, SessionRenamedFrame, SessionCreatedFrame, SessionArchivedFrame, SessionDeletedFrame, SessionWorkspaceUpdatedFrame, ChatCreatedFrame, ChatUpdatedFrame, ChatArchivedFrame, ChatUnarchivedFrame, ChatDeletedFrame, ProjectCreatedFrame, ProjectArchivedFrame, ProjectUnarchivedFrame, ProjectUpdatedFrame, ProjectDeletedFrame, ]); export type WsFrame = z.infer; // Convenience: the set of known frame types. Useful for the publishFrame // helper to log the offending type name when validation fails. Kept in sync // by hand with the discriminated union above. export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [ 'snapshot', 'message_started', 'delta', 'reasoning_delta', 'tool_call', 'tool_result', 'message_complete', 'usage', 'messages_deleted', 'chat_renamed', 'compacted', 'error', 'permission_requested', 'permission_resolved', 'agent_commands', 'chat_status', 'session_updated', 'session_renamed', 'session_created', 'session_archived', 'session_deleted', 'session_workspace_updated', 'chat_created', 'chat_updated', 'chat_archived', 'chat_unarchived', 'chat_deleted', 'project_created', 'project_archived', 'project_unarchived', 'project_updated', 'project_deleted', ] as const;