import { afterEach, describe, expect, mock, test } from "bun:test"; import { ApiError } from "./api-client"; import { attachSessionEvents, streamSessionTurn, type SessionStreamEvent, } from "./session-stream"; const encoder = new TextEncoder(); afterEach(() => { mock.restore(); }); function streamResponse(chunks: string[], status = 200, headers: HeadersInit = {}) { return new Response( new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk)); } controller.close(); }, }), { status, headers: { "content-type": "text/event-stream", ...headers, }, }, ); } describe("session stream helpers", () => { test("posts a turn and parses chunked SSE event envelopes", async () => { const events: SessionStreamEvent[] = []; const fetchMock = mock(() => Promise.resolve( streamResponse( [ "id: 3\nevent: run.session.turn.started\n", 'data: {"seq":3,"id":"evt_1","ts":"2026-05-22T16:25:34.940200Z","run_id":"run_1","event":"run.session.turn.started","properties":{"turn_id":"turn_1"}}\n\n', ], 200, { "x-fabro-turn-id": "turn_1" }, ), ), ); const result = await streamSessionTurn({ sessionId: "ses_1", input: "Summarize", turnId: "turn_1", fetchImpl: fetchMock, onEvent: (event) => events.push(event), }); expect(result.turnId).toBe("turn_1"); expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/v1/sessions/ses_1/turns"); expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual({ input: "Summarize", turn_id: "turn_1", }); expect(events).toHaveLength(1); expect(events[0]?.seq).toBe(3); expect(events[0]?.event).toBe("run.session.turn.started"); }); test("attaches to session events from a run sequence", async () => { const events: SessionStreamEvent[] = []; const fetchMock = mock(() => Promise.resolve( streamResponse([ 'data: {"seq":7,"id":"evt_2","ts":"2026-05-22T16:25:34.940200Z","run_id":"run_1","event":"run.session.assistant_message","properties":{}}\n\n', ]), ), ); await attachSessionEvents({ sessionId: "ses_1", sinceSeq: 7, fetchImpl: fetchMock, onEvent: (event) => events.push(event), }); expect(fetchMock.mock.calls[0]?.[0]).toBe( "/api/v1/sessions/ses_1/attach?since_seq=7", ); expect(events[0]?.seq).toBe(7); }); test("parses CRLF-delimited SSE frames", async () => { const events: SessionStreamEvent[] = []; const fetchMock = mock(() => Promise.resolve( streamResponse([ 'data: {"seq":8,"id":"evt_3","ts":"2026-05-22T16:25:34.940200Z","run_id":"run_1","event":"run.session.assistant_message","properties":{}}\r\n\r\n', ]), ), ); await attachSessionEvents({ sessionId: "ses_1", fetchImpl: fetchMock, onEvent: (event) => events.push(event), }); expect(events[0]?.seq).toBe(8); }); test("converts non-2xx responses to ApiError", async () => { const fetchMock = mock(() => Promise.resolve( new Response( JSON.stringify({ errors: [{ status: "409", title: "Conflict", detail: "Session already has an active turn.", code: "session_active_turn", }], }), { status: 409, headers: { "x-request-id": "req_1" }, }, ), ), ); await expect( streamSessionTurn({ sessionId: "ses_1", input: "Summarize", fetchImpl: fetchMock, onEvent: () => {}, }), ).rejects.toMatchObject({ status: 409, requestId: "req_1", message: "Session already has an active turn.", } satisfies Partial); }); });