fix(frontend): harden the streaming ui and extract copy and constants
This commit is contained in:
parent
036ef3cc6f
commit
a3af8f45cc
36 changed files with 1662 additions and 226 deletions
242
frontend/tests/useChatStream.test.ts
Normal file
242
frontend/tests/useChatStream.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue