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() return { ...actual, streamRecommendations: vi.fn() } }) const streamMock = vi.mocked(streamRecommendations) const unmountCallbacks: Array<() => void> = [] function mountChat() { let chat: ReturnType | 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): 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((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((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) }) })