import type { ChatEvent } from '../gen/apf-ai/chat'; /** * Translate one `apf.ai.v1.ChatEvent` into a single SSE frame for * the SPA, per ADR-0024 §"Sub-decision 2 — SSE bridge between BFF * and SPA". The mapping is intentionally one-to-one with the * proto `oneof` cases: * * token → `event: token` / data = TokenEvent JSON * citation → `event: citation` / data = CitationEvent JSON * agent_step → `event: agent-step` / data = AgentStepEvent JSON * tool_call → `event: tool-call` / data = ToolCallEvent JSON * error → `event: error` / data = ErrorEvent JSON * done → `event: done` / data = DoneEvent JSON * * SSE event names use kebab-case so the SPA's `EventSource` / * fetch-streaming consumer can dispatch with `.addEventListener('agent-step', …)` * without re-mapping proto camelCase. The terminal `done` frame is * the contract's stream-close marker — no `[DONE]` sentinel, per * ADR-0024. * * Returns `null` when the event carries no populated oneof case * (defensive — gRPC-js will not produce this in practice, but the * caller can safely skip on `null` rather than emit an empty * frame). The data payload is JSON.stringified; consumers parse * with `JSON.parse(event.data)`. */ export function chatEventToSseFrame(event: ChatEvent): string | null { if (event.token !== undefined) { return formatFrame('token', event.token); } if (event.citation !== undefined) { return formatFrame('citation', event.citation); } if (event.agentStep !== undefined) { return formatFrame('agent-step', event.agentStep); } if (event.toolCall !== undefined) { return formatFrame('tool-call', event.toolCall); } if (event.error !== undefined) { return formatFrame('error', event.error); } if (event.done !== undefined) { return formatFrame('done', event.done); } return null; } /** * Convenience used by the controller's error path: synthesise a * `urn:apf-ai:relay_error` event frame so the SPA receives a * structured failure rather than a torn-down connection. Matches the * shape of the AI service's own `ErrorEvent` so the SPA's renderer * does not need a second code path for relay-level failures vs * upstream model errors. */ export function relayErrorFrame(code: string, message: string, retriable = false): string { return formatFrame('error', { code, message, retriable }); } function formatFrame(eventName: string, data: unknown): string { // SSE spec: every field line ends with `\n`, the frame is // terminated by a blank line (`\n\n`). `data:` is followed by a // single space convention. `JSON.stringify` produces no newlines // for plain objects, so a single `data:` line is correct; a // multi-line payload (not used by this codec) would require // splitting on `\n` and prefixing each part with `data:`. return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; }