import type { Sql } from '../db.js'; import type { WsFrame } from '@boocode/contracts/ws-frames'; export type TerminalMessageStatus = 'cancelled' | 'failed'; /** * F1 (D-7) — decide the terminal status a Stop'd / errored external turn lands in. * * A user Stop (the per-task AbortController fired) or a thrown `AbortError` is a * deliberate, non-error outcome → `'cancelled'`. A genuine thrown error → `'failed'`. * Keeping the two distinct keeps the human-inbox / failure surfaces honest. * * Pure (no DB / IO) so the mapping is unit-testable in isolation. */ export function classifyTerminalStatus(opts: { aborted: boolean; error?: unknown }): TerminalMessageStatus { if (opts.aborted) return 'cancelled'; if (opts.error instanceof Error && opts.error.name === 'AbortError') return 'cancelled'; return 'failed'; } /** * F1 (OCE-001 / OCE-002) — finalize a streaming assistant message into a terminal * state and publish the matching `message_complete` frame. * * Idempotent via `WHERE status = 'streaming'`: a second call (a double-Stop, or an * abort short-circuit followed by the catch block) updates zero rows and does NOT * re-publish, so the frontend reducer settles the message exactly once. It also * never clobbers a row that already finished cleanly (`complete`) — the abort that * raced a clean finish is a no-op. * * Returns `true` iff this call performed the finalization (the row was still * streaming); `false` if it was already terminal or the id is absent (the throw * preceded the row's creation). */ export async function finalizeStreamingMessage( sql: Sql, publishFrame: (sessionId: string, frame: WsFrame) => void, opts: { sessionId: string; chatId: string; assistantId: string; status: TerminalMessageStatus; model: string | null; /** Partial accumulated text to persist; omit to leave the row's content untouched. */ content?: string; }, ): Promise { const { sessionId, chatId, assistantId, status, model, content } = opts; if (!assistantId) return false; const rows = content !== undefined ? await sql<{ id: string }[]>` UPDATE messages SET content = ${content}, status = ${status}, finished_at = clock_timestamp() WHERE id = ${assistantId} AND status = 'streaming' RETURNING id ` : await sql<{ id: string }[]>` UPDATE messages SET status = ${status}, finished_at = clock_timestamp() WHERE id = ${assistantId} AND status = 'streaming' RETURNING id `; if (rows.length === 0) return false; publishFrame(sessionId, { type: 'message_complete', message_id: assistantId, chat_id: chatId, model, status, } as WsFrame); return true; }