/** * AG-UI-style SSE event helpers. * * We don't implement the full AG-UI protocol — we use a simplified event set * that maps cleanly to our chat UX: * * text_delta — append text to the current assistant message * tool_start — model is calling a tool (renders collapsible block) * tool_result — local handler finished (fills the block) * navigate — render a clickable navigation button inline * done — terminal event; carries usage + final assistant message id * error — terminal event; carries error detail * * Each is emitted as one SSE message: * * event: * data: * * */ export type AGUIEvent = | { type: "text_delta"; delta: string } | { type: "tool_start"; id: string; name: string; args: Record } | { type: "tool_result"; id: string; result: unknown; durationMs?: number } | { type: "navigate"; target: string; label: string } | { type: "done"; provider: string; model: string; usage?: Record; messageId?: string } | { type: "error"; message: string }; /** * Encode an event into the byte-stream chunks expected by the SSE protocol. */ export function encodeEvent(ev: AGUIEvent): Uint8Array { const enc = new TextEncoder(); const json = JSON.stringify(ev); return enc.encode(`event: ${ev.type}\ndata: ${json}\n\n`); } /** * Helper that creates a ReadableStream + a typed `emit()` callback to push * events into it. Caller closes the stream by calling `emit({type:"done"})` or * `close()`. */ export function createEventStream(): { stream: ReadableStream; emit: (ev: AGUIEvent) => void; close: () => void; } { let controller!: ReadableStreamDefaultController; const stream = new ReadableStream({ start(c) { controller = c; }, }); return { stream, emit(ev) { try { controller.enqueue(encodeEvent(ev)); } catch { /* stream closed */ } }, close() { try { controller.close(); } catch { /* already closed */ } }, }; }