import { describe, expect, it } from 'vitest' import { readLeadingDecisionFrame } from '../sse-decision-frame' /** Build a `Response` whose body streams the given chunks back to the * reader one tick at a time. Mirrors the way the chat-agent route * emits frames over time. */ function streamingResponse(chunks: Uint8Array[]): Response { const encoder = new TextEncoder() void encoder // not strictly used; kept to mirror call site idiom const body = new ReadableStream({ async start(controller) { for (const chunk of chunks) { controller.enqueue(chunk) // Yield so the consumer's read loop interleaves the chunks // realistically (the loop awaits between reads). await Promise.resolve() } controller.close() }, }) return new Response(body) } function bytes(text: string): Uint8Array { return new TextEncoder().encode(text) } describe('readLeadingDecisionFrame', () => { it('returns the decision_resolved frame on a happy single-frame stream', async () => { const frame = { kind: 'decision_resolved', ok: true, action: 'approved', toolName: 'create_ticket', result: { ticket_id: '42', status: 'OPEN' }, } const wire = bytes(JSON.stringify(frame) + '\0') const result = await readLeadingDecisionFrame(streamingResponse([wire])) expect(result.kind).toBe('decision_resolved') expect(result.ok).toBe(true) expect(result.action).toBe('approved') expect(result.toolName).toBe('create_ticket') expect(result.result?.ticket_id).toBe('42') }) it('handles a rejected action', async () => { const frame = { kind: 'decision_resolved', ok: true, action: 'rejected' } const wire = bytes(JSON.stringify(frame) + '\0') const result = await readLeadingDecisionFrame(streamingResponse([wire])) expect(result.action).toBe('rejected') }) it('drains the stream defensively when extra bytes arrive after the frame', async () => { const frame = { kind: 'decision_resolved', ok: true, action: 'approved' } const trailing = 'unexpected trailing bytes from a misbehaving server' const wire = bytes(JSON.stringify(frame) + '\0' + trailing) const result = await readLeadingDecisionFrame(streamingResponse([wire])) expect(result.action).toBe('approved') // No throw — the helper swallowed the trailing bytes. }) it('rejects when the body is empty', async () => { const empty = streamingResponse([]) await expect(readLeadingDecisionFrame(empty)).rejects.toThrow(/closed before leading frame/i) }) it('rejects when the first frame has the wrong kind', async () => { const frame = { kind: 'approval_request', proposalId: 'x' } const wire = bytes(JSON.stringify(frame) + '\0') await expect(readLeadingDecisionFrame(streamingResponse([wire]))).rejects.toThrow( /expected decision_resolved/i, ) }) it('rejects when the leading JSON is malformed', async () => { const wire = bytes('not-json{\0') await expect(readLeadingDecisionFrame(streamingResponse([wire]))).rejects.toThrow( /leading JSON parse failed/i, ) }) it('rejects when the text-body sentinel arrives before any frame', async () => { const wire = bytes('\x1Ehello') await expect(readLeadingDecisionFrame(streamingResponse([wire]))).rejects.toThrow( /text-body sentinel arrived before leading frame/i, ) }) it('rejects when the response has no body', async () => { const bodiless = new Response(null, { status: 204 }) await expect(readLeadingDecisionFrame(bodiless)).rejects.toThrow(/response has no body/i) }) it('handles a frame split across multiple chunks', async () => { const frame = { kind: 'decision_resolved', ok: true, action: 'approved', result: { ticket_id: '99', mirror_synced: false }, } const text = JSON.stringify(frame) + '\0' // Split mid-JSON to simulate a network chunk boundary in the middle // of the payload. const split = Math.floor(text.length / 2) const result = await readLeadingDecisionFrame( streamingResponse([bytes(text.slice(0, split)), bytes(text.slice(split))]), ) expect(result.result?.ticket_id).toBe('99') expect(result.result?.mirror_synced).toBe(false) }) it('preserves all envelope fields the chat-agent route emits', async () => { const frame = { kind: 'decision_resolved', ok: true, action: 'approved', toolName: 'update_ticket', willAutoContinue: false, proposalId: 'prop-123', result: { ticket_id: 'tkt-7', status: 'CLOSED' }, receiptText: 'Ticket closed.', } const wire = bytes(JSON.stringify(frame) + '\0') const result = await readLeadingDecisionFrame(streamingResponse([wire])) expect(result.toolName).toBe('update_ticket') expect(result.proposalId).toBe('prop-123') expect(result.receiptText).toBe('Ticket closed.') }) })