/** * A manual SSE frame parser over `fetch` + `ReadableStream` — never the * `EventSource` API, which cannot send the auth header. Handles frames * split across stream chunks and multi-line `data:` fields. */ export interface SseFrame { readonly event: string; /** The concatenated `data:` lines (joined by "\n"). */ readonly data: string; /** * The frame's `id:` field when present — the server's resume token, echoed back * as `Last-Event-ID` on reconnect so the stream resumes after it. Absent for * frames without an `id:` line (e.g. the agent-run stream, which does not use it). */ readonly id?: string; } // CANONICAL constraint for every SSE open in this client. Engines coalesce // concurrent fetches to an IDENTICAL URL onto one connection, serializing later // opens behind the first — and an SSE body never ends, so a second identical open // would block forever (Firefox does this deterministically). Any stream two // consumers can open at once (the always-mounted interactions badge + the inbox // page; two views of one agent run) MUST give each open a distinct URL. Append // this token to the SSE request URL; servers ignore it. let sseOpenSeq = 0; export function sseOpenToken(): string { sseOpenSeq += 1; return sseOpenSeq.toString(36); } /** * Incrementally parse SSE text. Feed it chunks; it yields complete frames and * retains any partial trailing frame across calls. A frame ends on a blank line. */ export class SseFrameParser { private buffer = ''; /** Push a decoded text chunk; return every complete frame it now contains. */ push(chunk: string): SseFrame[] { this.buffer += chunk; const frames: SseFrame[] = []; let sep = this.findSeparator(); while (sep !== -1) { const raw = this.buffer.slice(0, sep.index); this.buffer = this.buffer.slice(sep.index + sep.length); const frame = parseFrame(raw); if (frame) frames.push(frame); sep = this.findSeparator(); } return frames; } private findSeparator(): { index: number; length: number } | -1 { const lf = this.buffer.indexOf('\n\n'); const crlf = this.buffer.indexOf('\r\n\r\n'); if (crlf !== -1 && (lf === -1 || crlf < lf)) return { index: crlf, length: 4 }; if (lf !== -1) return { index: lf, length: 2 }; return -1; } } function parseFrame(raw: string): SseFrame | null { let event = 'message'; let id: string | undefined; const dataLines: string[] = []; for (const line of raw.split(/\r?\n/)) { if (line.startsWith(':')) continue; // comment const colon = line.indexOf(':'); const field = colon === -1 ? line : line.slice(0, colon); // A single leading space after the colon is stripped per the SSE spec. let value = colon === -1 ? '' : line.slice(colon + 1); if (value.startsWith(' ')) value = value.slice(1); if (field === 'event') event = value; else if (field === 'data') dataLines.push(value); else if (field === 'id') id = value; } if (dataLines.length === 0 && event === 'message') return null; return id === undefined ? { event, data: dataLines.join('\n') } : { event, data: dataLines.join('\n'), id }; } /** * Consume an authed SSE endpoint as an async iterator of frames. The caller * supplies the fetch (with the auth header + abort signal) and handles * reconnect semantics. */ export async function* readSseFrames( response: Response, signal?: AbortSignal, ): AsyncGenerator { if (!response.body) throw new Error('SSE response has no body'); const reader = response.body.getReader(); const decoder = new TextDecoder(); const parser = new SseFrameParser(); try { for (;;) { // An abort is a loud termination, never a silent iterator end: surface the // signal's AbortError so the caller can tell a cancelled stream apart from a // clean close. This matches fetch/ReadableStream, which reject an in-flight // read on abort; the caller's own abort guard swallows it where cancellation // is the expected outcome. signal?.throwIfAborted(); const { value, done } = await reader.read(); if (done) break; for (const frame of parser.push(decoder.decode(value, { stream: true }))) { yield frame; } } } finally { reader.releaseLock(); } }