Files
bDS/tests/engine/streaming.test.ts

1108 lines
36 KiB
TypeScript

/**
* Tests for SSE streaming infrastructure (PR 1)
*
* Covers:
* - SSE line parsing (buffering partial lines across TCP chunks)
* - OpenAI/Mistral SSE event parsing (text deltas, tool calls, usage, [DONE])
* - Anthropic SSE event parsing (message_start, content_block_delta, etc.)
* - Tool-call argument accumulation during streaming
* - Error handling (mid-stream errors, non-2xx status, abort)
* - Retry with exponential backoff (429/502/503, Retry-After, no retry on 4xx/abort)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import http from 'http';
import {
parseSSELines,
parseOpenAIStreamEvent,
parseAnthropicStreamEvent,
withRetry,
httpRequestStream,
type SSEEvent,
type OpenAIStreamAccumulator,
type AnthropicStreamAccumulator,
createOpenAIStreamAccumulator,
createAnthropicStreamAccumulator,
} from '../../src/main/engine/streaming';
// ── SSE Line Parsing ──
describe('parseSSELines', () => {
it('parses a complete SSE event from a single chunk', () => {
const buffer = '';
const chunk = 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}\n\n';
const { events, remaining } = parseSSELines(buffer + chunk);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ event: undefined, data: '{"id":"1","choices":[{"delta":{"content":"Hello"}}]}' });
expect(remaining).toBe('');
});
it('handles partial lines across TCP chunks', () => {
// First chunk ends mid-line
const chunk1 = 'data: {"id":"1","cho';
const { events: events1, remaining: rem1 } = parseSSELines(chunk1);
expect(events1).toHaveLength(0);
expect(rem1).toBe('data: {"id":"1","cho');
// Second chunk completes the line
const chunk2 = 'ices":[{"delta":{"content":"Hello"}}]}\n\n';
const { events: events2, remaining: rem2 } = parseSSELines(rem1 + chunk2);
expect(events2).toHaveLength(1);
expect(events2[0].data).toBe('{"id":"1","choices":[{"delta":{"content":"Hello"}}]}');
expect(rem2).toBe('');
});
it('handles multiple events in a single chunk', () => {
const chunk = 'data: {"a":1}\n\ndata: {"b":2}\n\n';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(2);
expect(events[0].data).toBe('{"a":1}');
expect(events[1].data).toBe('{"b":2}');
expect(remaining).toBe('');
});
it('handles named event types (Anthropic format)', () => {
const chunk = 'event: message_start\ndata: {"type":"message_start"}\n\n';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(1);
expect(events[0].event).toBe('message_start');
expect(events[0].data).toBe('{"type":"message_start"}');
expect(remaining).toBe('');
});
it('handles [DONE] sentinel', () => {
const chunk = 'data: [DONE]\n\n';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(1);
expect(events[0].data).toBe('[DONE]');
expect(remaining).toBe('');
});
it('ignores empty data lines (keep-alive pings)', () => {
const chunk = ':\n\ndata: {"a":1}\n\n';
const { events, remaining } = parseSSELines(chunk);
// The comment line ':' should be ignored
expect(events).toHaveLength(1);
expect(events[0].data).toBe('{"a":1}');
expect(remaining).toBe('');
});
it('handles multiple data lines for a single event (concatenation per SSE spec)', () => {
const chunk = 'data: line1\ndata: line2\n\n';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(1);
expect(events[0].data).toBe('line1\nline2');
expect(remaining).toBe('');
});
it('returns incomplete data as remaining buffer', () => {
const chunk = 'data: {"partial';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(0);
expect(remaining).toBe('data: {"partial');
});
it('handles \\r\\n line endings', () => {
const chunk = 'data: {"a":1}\r\n\r\n';
const { events, remaining } = parseSSELines(chunk);
expect(events).toHaveLength(1);
expect(events[0].data).toBe('{"a":1}');
expect(remaining).toBe('');
});
});
// ── OpenAI/Mistral Stream Event Parsing ──
describe('parseOpenAIStreamEvent', () => {
let accumulator: OpenAIStreamAccumulator;
beforeEach(() => {
accumulator = createOpenAIStreamAccumulator();
});
it('extracts text delta from content field', () => {
const event: SSEEvent = {
data: JSON.stringify({
id: 'chatcmpl-1',
choices: [{ delta: { content: 'Hello' }, index: 0 }],
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.textDelta).toBe('Hello');
expect(result.done).toBe(false);
});
it('accumulates tool call start (id + name)', () => {
const event: SSEEvent = {
data: JSON.stringify({
id: 'chatcmpl-1',
choices: [{
delta: {
tool_calls: [{
index: 0,
id: 'call_abc',
function: { name: 'search_posts', arguments: '' },
}],
},
index: 0,
}],
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.textDelta).toBeUndefined();
expect(accumulator.toolCalls.get(0)).toEqual({
id: 'call_abc',
name: 'search_posts',
arguments: '',
});
});
it('accumulates tool call argument fragments', () => {
// First event: tool call start
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: {
tool_calls: [{
index: 0, id: 'call_abc',
function: { name: 'search_posts', arguments: '' },
}],
},
index: 0,
}],
}),
}, accumulator);
// Second event: argument fragment
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: {
tool_calls: [{
index: 0,
function: { arguments: '{"query"' },
}],
},
index: 0,
}],
}),
}, accumulator);
// Third event: more arguments
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: {
tool_calls: [{
index: 0,
function: { arguments: ': "test"}' },
}],
},
index: 0,
}],
}),
}, accumulator);
expect(accumulator.toolCalls.get(0)?.arguments).toBe('{"query": "test"}');
});
it('handles multiple concurrent tool calls', () => {
// Tool call 0
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: {
tool_calls: [
{ index: 0, id: 'call_1', function: { name: 'search_posts', arguments: '{"q":"a"}' } },
{ index: 1, id: 'call_2', function: { name: 'list_posts', arguments: '{"limit":5}' } },
],
},
index: 0,
}],
}),
}, accumulator);
expect(accumulator.toolCalls.get(0)?.name).toBe('search_posts');
expect(accumulator.toolCalls.get(1)?.name).toBe('list_posts');
});
it('detects finish_reason stop', () => {
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: {}, finish_reason: 'stop', index: 0 }],
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.finishReason).toBe('stop');
});
it('detects finish_reason tool_calls', () => {
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }],
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.finishReason).toBe('tool_calls');
});
it('extracts token usage from final chunk', () => {
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: {}, index: 0 }],
usage: {
prompt_tokens: 150,
completion_tokens: 42,
total_tokens: 192,
},
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.usage).toEqual({
promptTokens: 150,
completionTokens: 42,
totalTokens: 192,
});
});
it('handles [DONE] sentinel', () => {
const event: SSEEvent = { data: '[DONE]' };
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.done).toBe(true);
});
it('returns empty result for empty content delta', () => {
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: { content: '' }, index: 0 }],
}),
};
const result = parseOpenAIStreamEvent(event, accumulator);
expect(result.textDelta).toBeUndefined();
});
});
// ── Anthropic Stream Event Parsing ──
describe('parseAnthropicStreamEvent', () => {
let accumulator: AnthropicStreamAccumulator;
beforeEach(() => {
accumulator = createAnthropicStreamAccumulator();
});
it('extracts input_tokens from message_start', () => {
const event: SSEEvent = {
event: 'message_start',
data: JSON.stringify({
type: 'message_start',
message: {
id: 'msg_1',
model: 'claude-sonnet-4-5',
usage: {
input_tokens: 150,
cache_read_input_tokens: 50,
cache_creation_input_tokens: 10,
},
},
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.usage).toEqual({
inputTokens: 150,
cacheReadTokens: 50,
cacheWriteTokens: 10,
});
});
it('handles text content_block_start (no-op)', () => {
const event: SSEEvent = {
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.textDelta).toBeUndefined();
});
it('handles tool_use content_block_start', () => {
const event: SSEEvent = {
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 'toolu_abc', name: 'search_posts' },
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.textDelta).toBeUndefined();
expect(accumulator.toolCalls.get(1)).toEqual({
id: 'toolu_abc',
name: 'search_posts',
arguments: '',
});
});
it('extracts text_delta from content_block_delta', () => {
const event: SSEEvent = {
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'Hello world' },
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.textDelta).toBe('Hello world');
});
it('accumulates tool input_json_delta fragments', () => {
// Start tool block
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 'toolu_abc', name: 'search_posts' },
}),
}, accumulator);
// First argument fragment
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 1,
delta: { type: 'input_json_delta', partial_json: '{"query"' },
}),
}, accumulator);
// Second argument fragment
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 1,
delta: { type: 'input_json_delta', partial_json: ': "test"}' },
}),
}, accumulator);
expect(accumulator.toolCalls.get(1)?.arguments).toBe('{"query": "test"}');
});
it('extracts output_tokens from message_delta', () => {
const event: SSEEvent = {
event: 'message_delta',
data: JSON.stringify({
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 42 },
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.usage).toEqual({ outputTokens: 42 });
expect(result.finishReason).toBe('end_turn');
});
it('signals done on message_stop', () => {
const event: SSEEvent = {
event: 'message_stop',
data: JSON.stringify({ type: 'message_stop' }),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.done).toBe(true);
});
it('ignores ping events', () => {
const event: SSEEvent = {
event: 'ping',
data: JSON.stringify({ type: 'ping' }),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.textDelta).toBeUndefined();
expect(result.done).toBe(false);
});
it('throws on error events', () => {
const event: SSEEvent = {
event: 'error',
data: JSON.stringify({
type: 'error',
error: { type: 'overloaded_error', message: 'Server is overloaded' },
}),
};
expect(() => parseAnthropicStreamEvent(event, accumulator)).toThrow('Server is overloaded');
});
it('signals tool_use finish reason from message_delta', () => {
const event: SSEEvent = {
event: 'message_delta',
data: JSON.stringify({
type: 'message_delta',
delta: { stop_reason: 'tool_use' },
usage: { output_tokens: 10 },
}),
};
const result = parseAnthropicStreamEvent(event, accumulator);
expect(result.finishReason).toBe('tool_use');
});
});
// ── Tool Call Accumulation ──
describe('tool call accumulation', () => {
it('OpenAI: builds complete tool calls from fragments', () => {
const acc = createOpenAIStreamAccumulator();
// Start
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: {
tool_calls: [{
index: 0, id: 'call_1',
function: { name: 'search_posts', arguments: '' },
}],
},
index: 0,
}],
}),
}, acc);
// Fragments
for (const frag of ['{"', 'query', '": "', 'hello', '"}']) {
parseOpenAIStreamEvent({
data: JSON.stringify({
choices: [{
delta: { tool_calls: [{ index: 0, function: { arguments: frag } }] },
index: 0,
}],
}),
}, acc);
}
const tc = acc.toolCalls.get(0)!;
expect(tc.id).toBe('call_1');
expect(tc.name).toBe('search_posts');
expect(JSON.parse(tc.arguments)).toEqual({ query: 'hello' });
});
it('Anthropic: builds complete tool calls from fragments', () => {
const acc = createAnthropicStreamAccumulator();
// Start block
parseAnthropicStreamEvent({
event: 'content_block_start',
data: JSON.stringify({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 'toolu_1', name: 'list_posts' },
}),
}, acc);
// Fragments
for (const frag of ['{"', 'limit', '": ', '5}']) {
parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 1,
delta: { type: 'input_json_delta', partial_json: frag },
}),
}, acc);
}
const tc = acc.toolCalls.get(1)!;
expect(tc.id).toBe('toolu_1');
expect(tc.name).toBe('list_posts');
expect(JSON.parse(tc.arguments)).toEqual({ limit: 5 });
});
});
// ── Retry with Exponential Backoff ──
describe('withRetry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('returns result on first successful call', async () => {
const fn = vi.fn().mockResolvedValue('success');
const promise = withRetry(fn, { maxRetries: 3 });
const result = await promise;
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries on 429 status and succeeds', async () => {
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
const fn = vi.fn()
.mockRejectedValueOnce(error429)
.mockResolvedValue('success');
const promise = withRetry(fn, { maxRetries: 3 });
// Advance past the retry delay
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('success');
expect(fn).toHaveBeenCalledTimes(2);
});
it('retries on 502 status', async () => {
const error502 = Object.assign(new Error('Bad Gateway'), { statusCode: 502 });
const fn = vi.fn()
.mockRejectedValueOnce(error502)
.mockResolvedValue('ok');
const promise = withRetry(fn, { maxRetries: 3 });
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
it('retries on 503 status', async () => {
const error503 = Object.assign(new Error('Service Unavailable'), { statusCode: 503 });
const fn = vi.fn()
.mockRejectedValueOnce(error503)
.mockResolvedValue('ok');
const promise = withRetry(fn, { maxRetries: 3 });
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
it('does NOT retry on 400 status', async () => {
const error400 = Object.assign(new Error('Bad Request'), { statusCode: 400 });
const fn = vi.fn().mockRejectedValue(error400);
await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Bad Request');
expect(fn).toHaveBeenCalledTimes(1);
});
it('does NOT retry on 401 status', async () => {
const error401 = Object.assign(new Error('Unauthorized'), { statusCode: 401 });
const fn = vi.fn().mockRejectedValue(error401);
await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Unauthorized');
expect(fn).toHaveBeenCalledTimes(1);
});
it('does NOT retry on 403 status', async () => {
const error403 = Object.assign(new Error('Forbidden'), { statusCode: 403 });
const fn = vi.fn().mockRejectedValue(error403);
await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Forbidden');
expect(fn).toHaveBeenCalledTimes(1);
});
it('does NOT retry on abort', async () => {
const abortError = Object.assign(new Error('Request cancelled'), { isAbort: true });
const fn = vi.fn().mockRejectedValue(abortError);
await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Request cancelled');
expect(fn).toHaveBeenCalledTimes(1);
});
it('exhausts max retries and throws last error', async () => {
vi.useRealTimers(); // Real timers work better for this test
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
let callCount = 0;
const fn = vi.fn().mockImplementation(() => {
callCount++;
return Promise.reject(error429);
});
await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('Rate limited');
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
vi.useFakeTimers(); // Restore for afterEach
});
it('respects Retry-After header for 429', async () => {
const error429 = Object.assign(new Error('Rate limited'), {
statusCode: 429,
retryAfter: 5,
});
const fn = vi.fn()
.mockRejectedValueOnce(error429)
.mockResolvedValue('ok');
const promise = withRetry(fn, { maxRetries: 3 });
// Should NOT have retried yet at 3 seconds (Retry-After is 5)
await vi.advanceTimersByTimeAsync(3000);
expect(fn).toHaveBeenCalledTimes(1);
// Advance past the Retry-After
await vi.advanceTimersByTimeAsync(3000);
const result = await promise;
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(2);
});
afterEach(() => {
vi.useRealTimers();
});
});
// ── Full stream-to-result integration ──
describe('stream event sequences', () => {
it('OpenAI: processes a complete text response stream', () => {
const acc = createOpenAIStreamAccumulator();
const textChunks: string[] = [];
const events: SSEEvent[] = [
{ data: JSON.stringify({ choices: [{ delta: { role: 'assistant' }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: { content: 'Hello' }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: { content: ' world' }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: { content: '!' }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }) },
{ data: '[DONE]' },
];
for (const event of events) {
const result = parseOpenAIStreamEvent(event, acc);
if (result.textDelta) textChunks.push(result.textDelta);
}
expect(textChunks.join('')).toBe('Hello world!');
});
it('Anthropic: processes a complete text response stream', () => {
const acc = createAnthropicStreamAccumulator();
const textChunks: string[] = [];
const events: SSEEvent[] = [
{ event: 'message_start', data: JSON.stringify({ type: 'message_start', message: { id: 'msg_1', model: 'claude-sonnet-4', usage: { input_tokens: 100 } } }) },
{ event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world!' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 0 }) },
{ event: 'message_delta', data: JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } }) },
{ event: 'message_stop', data: JSON.stringify({ type: 'message_stop' }) },
];
for (const event of events) {
const result = parseAnthropicStreamEvent(event, acc);
if (result.textDelta) textChunks.push(result.textDelta);
}
expect(textChunks.join('')).toBe('Hello world!');
});
it('OpenAI: processes a tool call response stream', () => {
const acc = createOpenAIStreamAccumulator();
const events: SSEEvent[] = [
{ data: JSON.stringify({ choices: [{ delta: { role: 'assistant', tool_calls: [{ index: 0, id: 'call_1', function: { name: 'search_posts', arguments: '' } }] }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"query"' } }] }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "test"}' } }] }, index: 0 }] }) },
{ data: JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }] }) },
{ data: '[DONE]' },
];
for (const event of events) {
parseOpenAIStreamEvent(event, acc);
}
expect(acc.toolCalls.size).toBe(1);
const tc = acc.toolCalls.get(0)!;
expect(tc.name).toBe('search_posts');
expect(JSON.parse(tc.arguments)).toEqual({ query: 'test' });
});
it('Anthropic: processes a tool call response stream', () => {
const acc = createAnthropicStreamAccumulator();
const events: SSEEvent[] = [
{ event: 'message_start', data: JSON.stringify({ type: 'message_start', message: { id: 'msg_1', usage: { input_tokens: 100 } } }) },
{ event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Let me search.' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 0 }) },
{ event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_1', name: 'search_posts' } }) },
{ event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"query": "test"}' } }) },
{ event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 1 }) },
{ event: 'message_delta', data: JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 20 } }) },
{ event: 'message_stop', data: JSON.stringify({ type: 'message_stop' }) },
];
const textChunks: string[] = [];
for (const event of events) {
const result = parseAnthropicStreamEvent(event, acc);
if (result.textDelta) textChunks.push(result.textDelta);
}
expect(textChunks.join('')).toBe('Let me search.');
expect(acc.toolCalls.size).toBe(1);
const tc = acc.toolCalls.get(1)!;
expect(tc.name).toBe('search_posts');
expect(JSON.parse(tc.arguments)).toEqual({ query: 'test' });
});
});
// ── JSON Parse Error Resilience ──
describe('parser JSON error resilience', () => {
it('OpenAI: skips corrupted SSE events with invalid JSON', () => {
const acc = createOpenAIStreamAccumulator();
const event: SSEEvent = { data: '{corrupted json' };
const result = parseOpenAIStreamEvent(event, acc);
expect(result.done).toBe(false);
expect(result.textDelta).toBeUndefined();
});
it('OpenAI: recovers from corrupted event and processes subsequent valid events', () => {
const acc = createOpenAIStreamAccumulator();
// Corrupted event
parseOpenAIStreamEvent({ data: 'not-json' }, acc);
// Valid event after corruption
const result = parseOpenAIStreamEvent({
data: JSON.stringify({ choices: [{ delta: { content: 'OK' }, index: 0 }] }),
}, acc);
expect(result.textDelta).toBe('OK');
});
it('Anthropic: skips corrupted SSE events with invalid JSON', () => {
const acc = createAnthropicStreamAccumulator();
const event: SSEEvent = { event: 'content_block_delta', data: '{broken' };
const result = parseAnthropicStreamEvent(event, acc);
expect(result.done).toBe(false);
expect(result.textDelta).toBeUndefined();
});
it('Anthropic: recovers from corrupted event and processes subsequent valid events', () => {
const acc = createAnthropicStreamAccumulator();
// Corrupted event
parseAnthropicStreamEvent({ event: 'ping', data: 'not-json' }, acc);
// Valid event after corruption
const result = parseAnthropicStreamEvent({
event: 'content_block_delta',
data: JSON.stringify({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'Recovered' },
}),
}, acc);
expect(result.textDelta).toBe('Recovered');
});
});
// ── OpenAI Cache Token Extraction ──
describe('OpenAI cache token extraction', () => {
it('extracts cached_tokens from prompt_tokens_details', () => {
const acc = createOpenAIStreamAccumulator();
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: {}, index: 0 }],
usage: {
prompt_tokens: 150,
completion_tokens: 42,
total_tokens: 192,
prompt_tokens_details: { cached_tokens: 100 },
},
}),
};
const result = parseOpenAIStreamEvent(event, acc);
expect(result.usage).toEqual({
promptTokens: 150,
completionTokens: 42,
totalTokens: 192,
cacheReadTokens: 100,
});
});
it('returns undefined cacheReadTokens when prompt_tokens_details is absent', () => {
const acc = createOpenAIStreamAccumulator();
const event: SSEEvent = {
data: JSON.stringify({
choices: [{ delta: {}, index: 0 }],
usage: {
prompt_tokens: 150,
completion_tokens: 42,
total_tokens: 192,
},
}),
};
const result = parseOpenAIStreamEvent(event, acc);
expect(result.usage?.cacheReadTokens).toBeUndefined();
});
});
// ── httpRequestStream ──
describe('httpRequestStream', () => {
// Use a real HTTP server for integration tests (avoids ESM spyOn limitations)
function startTestServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, () => {
const addr = server.address() as { port: number };
resolve({
url: `http://localhost:${addr.port}`,
close: () => new Promise<void>((r) => server.close(() => r())),
});
});
});
}
it('parses streamed SSE events from response data chunks', async () => {
const srv = await startTestServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n');
res.write('data: {"choices":[{"delta":{"content":" world"}}]}\n\n');
res.write('data: [DONE]\n\n');
res.end();
});
try {
const { events } = await httpRequestStream(srv.url, { method: 'POST', body: '{}' });
const collected: SSEEvent[] = [];
for await (const event of events) {
collected.push(event);
}
expect(collected).toHaveLength(3);
expect(collected[0].data).toBe('{"choices":[{"delta":{"content":"Hello"}}]}');
expect(collected[1].data).toBe('{"choices":[{"delta":{"content":" world"}}]}');
expect(collected[2].data).toBe('[DONE]');
} finally {
await srv.close();
}
});
it('collects error body and rejects on non-2xx status', async () => {
const srv = await startTestServer((_req, res) => {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' });
res.end(JSON.stringify({ error: { message: 'Rate limited' } }));
});
try {
await expect(httpRequestStream(srv.url, {})).rejects.toMatchObject({
message: 'Rate limited',
statusCode: 429,
retryAfter: 5,
});
} finally {
await srv.close();
}
});
it('propagates mid-stream errors to async iterable consumer', async () => {
const srv = await startTestServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n');
// Destroy the socket to simulate TCP disconnect
setTimeout(() => res.destroy(), 20);
});
try {
const { events } = await httpRequestStream(srv.url, {});
const collected: SSEEvent[] = [];
await expect(async () => {
for await (const event of events) {
collected.push(event);
}
}).rejects.toThrow();
// Should have received the first event before the error
expect(collected).toHaveLength(1);
expect(collected[0].data).toBe('{"choices":[{"delta":{"content":"Hi"}}]}');
} finally {
await srv.close();
}
});
it('propagates stored error when no consumer was waiting (pendingError fix)', async () => {
const srv = await startTestServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
// Send data and immediately destroy — error fires before consumer calls .next()
res.write('data: {"ok":true}\n\n');
// Give a tiny delay so the data event fires first
setTimeout(() => res.destroy(), 5);
});
try {
const { events } = await httpRequestStream(srv.url, {});
const iter = events[Symbol.asyncIterator]();
// Wait a bit for both data and error to fire
await new Promise(resolve => setTimeout(resolve, 50));
// First call should return the queued event
const first = await iter.next();
expect(first.done).toBe(false);
expect(first.value.data).toBe('{"ok":true}');
// Second call should throw the stored (pending) error
await expect(iter.next()).rejects.toThrow();
} finally {
await srv.close();
}
});
it('handles already-aborted signal', async () => {
// No server needed — should reject immediately
const controller = new AbortController();
controller.abort();
await expect(httpRequestStream('http://localhost:1/test', {
signal: controller.signal,
})).rejects.toMatchObject({
isAbort: true,
});
});
it('handles non-JSON error body', async () => {
const srv = await startTestServer((_req, res) => {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
});
try {
await expect(httpRequestStream(srv.url, {})).rejects.toMatchObject({
statusCode: 500,
});
} finally {
await srv.close();
}
});
});
// ── withRetry onRetry callback ──
describe('withRetry onRetry callback', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls onRetry callback before each retry attempt', async () => {
const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 });
const onRetry = vi.fn();
const fn = vi.fn()
.mockRejectedValueOnce(error429)
.mockRejectedValueOnce(error429)
.mockResolvedValue('success');
const promise = withRetry(fn, { maxRetries: 3, onRetry });
await vi.advanceTimersByTimeAsync(10000);
const result = await promise;
expect(result).toBe('success');
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledWith(1, error429);
expect(onRetry).toHaveBeenCalledWith(2, error429);
});
it('does not call onRetry when first attempt succeeds', async () => {
const onRetry = vi.fn();
const fn = vi.fn().mockResolvedValue('ok');
const result = await withRetry(fn, { maxRetries: 3, onRetry });
expect(result).toBe('ok');
expect(onRetry).not.toHaveBeenCalled();
});
});
// ── Mid-stream retry integration ──
describe('mid-stream retry with withRetry', () => {
it('retries stream consumption on transient mid-stream error', async () => {
vi.useRealTimers();
let attempt = 0;
const fn = async () => {
attempt++;
if (attempt === 1) {
// First attempt: simulate partial stream then error
const error = Object.assign(new Error('Service temporarily unavailable'), { statusCode: 503 });
throw error;
}
// Second attempt: succeed
return { text: 'Hello world!', toolCalls: [] };
};
const result = await withRetry(fn, { maxRetries: 2 });
expect(result).toEqual({ text: 'Hello world!', toolCalls: [] });
expect(attempt).toBe(2);
});
it('retries on mid-stream TCP error (no status code)', async () => {
vi.useRealTimers();
let attempt = 0;
const fn = async () => {
attempt++;
if (attempt === 1) {
throw new Error('ECONNRESET');
}
return 'recovered';
};
const result = await withRetry(fn, { maxRetries: 2 });
expect(result).toBe('recovered');
expect(attempt).toBe(2);
});
it('does not retry mid-stream abort errors', async () => {
const abortError = Object.assign(new Error('Request cancelled'), { isAbort: true });
let attempt = 0;
const fn = async () => {
attempt++;
throw abortError;
};
await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Request cancelled');
expect(attempt).toBe(1);
});
});
// ── SSE spec compliance ──
describe('SSE spec compliance - single space removal', () => {
it('removes exactly one leading space after colon in data field', () => {
const chunk = 'data: {"key": "value"}\n\n';
const { events } = parseSSELines(chunk);
expect(events[0].data).toBe('{"key": "value"}');
});
it('preserves data when no space after colon', () => {
const chunk = 'data:{"key":"value"}\n\n';
const { events } = parseSSELines(chunk);
expect(events[0].data).toBe('{"key":"value"}');
});
it('preserves extra leading spaces after removing one', () => {
const chunk = 'data: two spaces\n\n';
const { events } = parseSSELines(chunk);
// Per SSE spec: only one leading space is removed
expect(events[0].data).toBe(' two spaces');
});
it('removes exactly one leading space from event type', () => {
const chunk = 'event: message_start\ndata: {}\n\n';
const { events } = parseSSELines(chunk);
expect(events[0].event).toBe('message_start');
});
it('handles event type with no space after colon', () => {
const chunk = 'event:ping\ndata: {}\n\n';
const { events } = parseSSELines(chunk);
expect(events[0].event).toBe('ping');
});
});