- 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)
867 lines
29 KiB
TypeScript
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');
|
|
});
|
|
});
|