173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
import {
|
|
parseSseEventBlock,
|
|
streamTalkResponse,
|
|
} from '../src/features/talk/llm-stream-service'
|
|
|
|
const originalFetch = global.fetch
|
|
const GENERATION_ID = '11111111-1111-4111-8111-111111111111'
|
|
|
|
function headers(
|
|
contentType: string,
|
|
generationId: string | null = GENERATION_ID,
|
|
): { get(name: string): string | null } {
|
|
return {
|
|
get: (name) => {
|
|
if (name.toLowerCase() === 'content-type') return contentType
|
|
if (name.toLowerCase() === 'x-d3ro-generation-id') return generationId
|
|
return null
|
|
},
|
|
}
|
|
}
|
|
|
|
function byteChunks(value: string, chunkSizes: number[]): Uint8Array[] {
|
|
const bytes = Uint8Array.from(Buffer.from(value, 'utf8'))
|
|
const chunks: Uint8Array[] = []
|
|
let offset = 0
|
|
for (const size of chunkSizes) {
|
|
if (offset >= bytes.length) break
|
|
chunks.push(bytes.slice(offset, Math.min(bytes.length, offset + size)))
|
|
offset += size
|
|
}
|
|
if (offset < bytes.length) chunks.push(bytes.slice(offset))
|
|
return chunks
|
|
}
|
|
|
|
function streamingResponse(chunks: Uint8Array[]): Response {
|
|
let index = 0
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
headers: headers('text/event-stream'),
|
|
body: {
|
|
getReader: () => ({
|
|
read: async () => index < chunks.length
|
|
? { done: false, value: chunks[index++] }
|
|
: { done: true, value: undefined },
|
|
releaseLock: jest.fn(),
|
|
}),
|
|
},
|
|
} as unknown as Response
|
|
}
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
jest.restoreAllMocks()
|
|
})
|
|
|
|
describe('Talk LLM streaming', () => {
|
|
it('parses SSE fields and joins multiple data lines', () => {
|
|
expect(parseSseEventBlock('event: message\ndata: {"a":1}\ndata: {"b":2}'))
|
|
.toEqual({ event: 'message', data: '{"a":1}\n{"b":2}' })
|
|
expect(parseSseEventBlock(': ping\nevent: ping')).toBeNull()
|
|
})
|
|
|
|
it('streams real Anthropic text deltas across UTF-8 and network chunk boundaries', async () => {
|
|
const sse = [
|
|
'event: content_block_delta\n',
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"안녕 "}}\n\n',
|
|
'event: content_block_delta\r\n',
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"world"}}\r\n\r\n',
|
|
'event: message_stop\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
].join('')
|
|
global.fetch = jest.fn().mockResolvedValue(streamingResponse(byteChunks(sse, [1, 2, 5, 7, 13, 3])))
|
|
const updates: string[] = []
|
|
|
|
const result = await streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{
|
|
accessToken: 'real-user-token',
|
|
onTextDelta: (_delta, accumulated) => updates.push(accumulated),
|
|
},
|
|
)
|
|
|
|
expect(result).toEqual({ text: '안녕 world', generationId: GENERATION_ID })
|
|
expect(updates).toEqual(['안녕 ', '안녕 world'])
|
|
const request = (global.fetch as jest.Mock).mock.calls[0][1]
|
|
expect(JSON.parse(request.body)).toMatchObject({ stream: true, max_tokens: 1024 })
|
|
expect(request.headers).toMatchObject({
|
|
Authorization: 'Bearer real-user-token',
|
|
Accept: 'text/event-stream',
|
|
'X-D3RO-Generation-Purpose': 'talk_response',
|
|
})
|
|
})
|
|
|
|
it('parses the same SSE response without issuing a second request when RN buffers the body', async () => {
|
|
const body = [
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"buffered"}}\n\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
].join('')
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: headers('text/event-stream'),
|
|
body: null,
|
|
text: async () => body,
|
|
})
|
|
const updates: string[] = []
|
|
|
|
await expect(streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{ accessToken: 'token', onTextDelta: (delta) => updates.push(delta) },
|
|
)).resolves.toEqual({ text: 'buffered', generationId: GENERATION_ID })
|
|
expect(updates).toEqual(['buffered'])
|
|
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('accepts a truthful non-streaming JSON result if the edge runtime cannot expose a stream body', async () => {
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: headers('application/json'),
|
|
json: async () => ({ content: [{ type: 'text', text: 'real answer' }] }),
|
|
})
|
|
const updates: string[] = []
|
|
|
|
await expect(streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{ accessToken: 'token', onTextDelta: (delta) => updates.push(delta) },
|
|
)).resolves.toEqual({ text: 'real answer', generationId: GENERATION_ID })
|
|
expect(updates).toEqual(['real answer'])
|
|
})
|
|
|
|
it('fails closed on truncated SSE and maps provider outages without fallback text', async () => {
|
|
global.fetch = jest.fn().mockResolvedValueOnce(streamingResponse(byteChunks(
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}\n\n',
|
|
[4, 9],
|
|
)))
|
|
await expect(streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{ accessToken: 'token', onTextDelta: jest.fn() },
|
|
)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
|
|
|
|
global.fetch = jest.fn().mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 503,
|
|
headers: headers('application/json'),
|
|
text: async () => JSON.stringify({ error: 'provider_unavailable' }),
|
|
})
|
|
await expect(streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{ accessToken: 'token', onTextDelta: jest.fn() },
|
|
)).rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE', retryable: true })
|
|
})
|
|
|
|
it('fails closed when a successful response has no reportable generation receipt', async () => {
|
|
const body = [
|
|
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"answer"}}\n\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
].join('')
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: headers('text/event-stream', null),
|
|
body: null,
|
|
text: async () => body,
|
|
})
|
|
|
|
await expect(streamTalkResponse(
|
|
[{ role: 'user', content: 'hello' }],
|
|
{ accessToken: 'token', onTextDelta: jest.fn() },
|
|
)).rejects.toMatchObject({ code: 'INVALID_RESPONSE', retryable: true })
|
|
})
|
|
})
|