242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
import { createApp, defineComponent, h } from 'vue'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useChatStream } from '../src/composables/useChatStream'
|
|
import { streamRecommendations, StreamTransportError } from '../src/lib/recommendationStream'
|
|
import type { AssistantTurn } from '../src/lib/models'
|
|
import type { RecommendationRequest, StreamEvent, TrackEvent } from '../src/lib/types'
|
|
|
|
vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../src/lib/recommendationStream')>()
|
|
return { ...actual, streamRecommendations: vi.fn() }
|
|
})
|
|
|
|
const streamMock = vi.mocked(streamRecommendations)
|
|
const unmountCallbacks: Array<() => void> = []
|
|
|
|
function mountChat() {
|
|
let chat: ReturnType<typeof useChatStream> | undefined
|
|
const root = document.createElement('div')
|
|
document.body.append(root)
|
|
const app = createApp(
|
|
defineComponent({
|
|
setup() {
|
|
chat = useChatStream(vi.fn(async () => ({ url: null })))
|
|
return () => h('div')
|
|
},
|
|
}),
|
|
)
|
|
app.mount(root)
|
|
unmountCallbacks.push(() => {
|
|
app.unmount()
|
|
root.remove()
|
|
})
|
|
if (!chat) throw new Error('Chat composable did not mount.')
|
|
return chat
|
|
}
|
|
|
|
function metadata(requestId: string): StreamEvent {
|
|
return {
|
|
type: 'metadata',
|
|
request_id: requestId,
|
|
intent_summary: `Intent for ${requestId}`,
|
|
candidate_count: 100,
|
|
}
|
|
}
|
|
|
|
function track(rank: number): TrackEvent {
|
|
return {
|
|
type: 'track',
|
|
rank,
|
|
track: {
|
|
id: `track-${rank}`,
|
|
uri: `spotify:track:${rank}`,
|
|
title: `Track ${rank}`,
|
|
artists: ['Artist'],
|
|
album_name: 'Album',
|
|
album_art_url: null,
|
|
external_url: null,
|
|
},
|
|
justification: 'It fits.',
|
|
}
|
|
}
|
|
|
|
function assistantTurns(chat: ReturnType<typeof useChatStream>): AssistantTurn[] {
|
|
return chat.turns.value.filter((turn): turn is AssistantTurn => turn.role === 'assistant')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
streamMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
|
|
})
|
|
|
|
describe('useChatStream', () => {
|
|
it('retains repeated warnings that share a code', async () => {
|
|
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
|
onEvent(metadata('warnings'))
|
|
onEvent({ type: 'warning', code: 'limited_pool', message: 'First warning.' })
|
|
onEvent({ type: 'warning', code: 'limited_pool', message: 'Second warning.' })
|
|
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
})
|
|
const chat = mountChat()
|
|
|
|
await chat.send('warnings')
|
|
|
|
expect(assistantTurns(chat)[0]?.warnings).toEqual([
|
|
{ type: 'warning', code: 'limited_pool', message: 'First warning.' },
|
|
{ type: 'warning', code: 'limited_pool', message: 'Second warning.' },
|
|
])
|
|
})
|
|
|
|
it('keeps error terminal when a late done callback arrives', async () => {
|
|
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
|
|
onEvent(metadata('error'))
|
|
onEvent({ type: 'error', code: 'upstream', message: 'Upstream failed.' })
|
|
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
})
|
|
const chat = mountChat()
|
|
|
|
await chat.send('error')
|
|
|
|
const turn = assistantTurns(chat)[0]
|
|
expect(turn).toMatchObject({ status: 'error', error: { code: 'upstream' } })
|
|
expect(turn?.completion).toBeNull()
|
|
expect(turn?.transportFailure).toBeNull()
|
|
})
|
|
|
|
it('stores an incomplete stream as a transport failure', async () => {
|
|
streamMock.mockRejectedValue(
|
|
new StreamTransportError('unexpected_eof', 'The stream ended early.'),
|
|
)
|
|
const chat = mountChat()
|
|
|
|
await chat.send('failure')
|
|
|
|
expect(assistantTurns(chat)[0]).toMatchObject({
|
|
status: 'error',
|
|
transportFailure: { kind: 'unexpected_eof' },
|
|
})
|
|
})
|
|
|
|
it('caps request history at 12 turns', async () => {
|
|
const requests: RecommendationRequest[] = []
|
|
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
|
requests.push(request)
|
|
onEvent(metadata(request.query))
|
|
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
})
|
|
const chat = mountChat()
|
|
|
|
for (let index = 0; index < 8; index += 1) {
|
|
await chat.send(`query ${index}`)
|
|
}
|
|
|
|
expect(requests.at(-1)?.history).toHaveLength(12)
|
|
expect(requests.at(-1)?.history[0]?.role).toBe('user')
|
|
})
|
|
|
|
it('caps prior recommendations at 50 tracks', async () => {
|
|
const requests: RecommendationRequest[] = []
|
|
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
|
requests.push(request)
|
|
onEvent(metadata(request.query))
|
|
if (requests.length === 1) {
|
|
for (let rank = 1; rank <= 60; rank += 1) onEvent(track(rank))
|
|
onEvent({ type: 'done', track_count: 60, total_ms: 5 })
|
|
} else {
|
|
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
}
|
|
})
|
|
const chat = mountChat()
|
|
|
|
await chat.send('first')
|
|
await chat.send('second')
|
|
|
|
expect(requests[1]?.prior_recommendations).toHaveLength(50)
|
|
expect(requests[1]?.prior_recommendations.at(-1)?.rank).toBe(50)
|
|
})
|
|
|
|
it('aborts a replaced stream and ignores its late callbacks and failure', async () => {
|
|
interface PendingStream {
|
|
signal: AbortSignal
|
|
onEvent: (event: StreamEvent) => void
|
|
resolve: () => void
|
|
reject: (error: unknown) => void
|
|
}
|
|
const pending: PendingStream[] = []
|
|
streamMock.mockImplementation(
|
|
(_request, signal, onEvent) =>
|
|
new Promise<void>((resolve, reject) => {
|
|
pending.push({ signal, onEvent, resolve, reject })
|
|
}),
|
|
)
|
|
const chat = mountChat()
|
|
|
|
const firstSend = chat.send('first')
|
|
const secondSend = chat.send('second')
|
|
expect(pending[0]?.signal.aborted).toBe(true)
|
|
|
|
pending[0]?.onEvent(metadata('late-first'))
|
|
pending[0]?.onEvent(track(1))
|
|
pending[0]?.reject(new DOMException('Aborted', 'AbortError'))
|
|
pending[1]?.onEvent(metadata('second'))
|
|
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
pending[1]?.resolve()
|
|
await Promise.all([firstSend, secondSend])
|
|
|
|
const [firstTurn, secondTurn] = assistantTurns(chat)
|
|
expect(firstTurn).toMatchObject({
|
|
status: 'error',
|
|
requestId: null,
|
|
tracks: [],
|
|
transportFailure: { kind: 'cancelled' },
|
|
})
|
|
expect(secondTurn).toMatchObject({ status: 'done', requestId: 'second' })
|
|
})
|
|
|
|
it('does not relabel a terminal turn while its reader is cleaning up', async () => {
|
|
interface PendingStream {
|
|
signal: AbortSignal
|
|
onEvent: (event: StreamEvent) => void
|
|
resolve: () => void
|
|
}
|
|
const pending: PendingStream[] = []
|
|
streamMock.mockImplementation(
|
|
(_request, signal, onEvent) =>
|
|
new Promise<void>((resolve) => {
|
|
pending.push({ signal, onEvent, resolve })
|
|
}),
|
|
)
|
|
const chat = mountChat()
|
|
|
|
const firstSend = chat.send('first')
|
|
pending[0]?.onEvent(metadata('first'))
|
|
pending[0]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
const secondSend = chat.send('second')
|
|
expect(pending[0]?.signal.aborted).toBe(true)
|
|
|
|
pending[0]?.resolve()
|
|
pending[1]?.onEvent(metadata('second'))
|
|
pending[1]?.onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
pending[1]?.resolve()
|
|
await Promise.all([firstSend, secondSend])
|
|
|
|
expect(assistantTurns(chat).map((turn) => turn.status)).toEqual(['done', 'done'])
|
|
})
|
|
|
|
it('caps a programmatic query at 1000 characters', async () => {
|
|
let receivedQuery = ''
|
|
streamMock.mockImplementation(async (request, _signal, onEvent) => {
|
|
receivedQuery = request.query
|
|
onEvent(metadata('query-limit'))
|
|
onEvent({ type: 'done', track_count: 0, total_ms: 5 })
|
|
})
|
|
const chat = mountChat()
|
|
|
|
await chat.send('x'.repeat(1200))
|
|
|
|
expect(receivedQuery).toHaveLength(1000)
|
|
})
|
|
})
|