Files
bDS/tests/engine/streaming.test.ts
hugo 781cedade5 fix: SSE streaming review fixes
- Fix OpenAI path using accumulatedText instead of round-specific text
  in assistant messages for multi-round tool conversations
- Guard JSON.parse in both SSE parsers against corrupted events
- Extract cacheReadTokens from OpenAI prompt_tokens_details when available
- Add tests for JSON parse resilience and cache token extraction (7 new tests)
2026-03-01 10:25:54 +01:00

867 lines
29 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 } from 'vitest';
import {
parseSSELines,
parseOpenAIStreamEvent,
parseAnthropicStreamEvent,
withRetry,
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', () => {
// We test httpRequestStream by mocking Node's http/https modules
// These tests verify the async iterable, error handling, and abort behavior
// Helper to create a mock response
function createMockResponse(statusCode: number) {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
return {
statusCode,
headers: {} as Record<string, string>,
on(event: string, handler: (...args: unknown[]) => void) {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
return this;
},
emit(event: string, ...args: unknown[]) {
for (const h of handlers[event] || []) h(...args);
},
};
}
it('should be importable', async () => {
// Verify the function exists and has the right shape
const { httpRequestStream } = await import('../../src/main/engine/streaming');
expect(typeof httpRequestStream).toBe('function');
});
});