feat: SSE streaming for chat providers
This commit is contained in:
743
tests/engine/streaming.test.ts
Normal file
743
tests/engine/streaming.test.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user