Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { createVoiceEventSequencer } from '../../packages/client/src/utils/voiceDialogueEvents'
|
|
import { useVoiceDialogue } from '../../packages/client/src/composables/useVoiceDialogue'
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
let reject!: (reason?: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
function localStorageSnapshot(): string {
|
|
return JSON.stringify(Object.fromEntries(
|
|
Array.from({ length: localStorage.length }, (_, index) => {
|
|
const key = localStorage.key(index) || ''
|
|
return [key, localStorage.getItem(key)]
|
|
}),
|
|
))
|
|
}
|
|
|
|
describe('createVoiceEventSequencer', () => {
|
|
it('emits monotonic seq/id pairs and preserves explicit timestamps', () => {
|
|
const nextEvent = createVoiceEventSequencer('session-1')
|
|
|
|
const first = nextEvent('capture.started', { captureId: 'capture-1', timestamp: '2026-06-06T00:00:00.000Z' })
|
|
const second = nextEvent('turn.started', { turnId: 'turn-1' })
|
|
|
|
expect(first.seq).toBe(1)
|
|
expect(first.id).toBe('session-1:1')
|
|
expect(first.timestamp).toBe('2026-06-06T00:00:00.000Z')
|
|
expect(second.seq).toBe(2)
|
|
expect(second.id).toBe('session-1:2')
|
|
expect(second.timestamp).toMatch(/Z$/)
|
|
})
|
|
})
|
|
|
|
describe('useVoiceDialogue', () => {
|
|
it('transcribes recorded audio and sends the normalized transcript', async () => {
|
|
const audio = new Blob(['voice'])
|
|
const sendMessage = vi.fn()
|
|
const transcribe = vi.fn().mockResolvedValue({ text: ' hello hermes ' })
|
|
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
await dialogue.transcribeAndSend(captureId, audio)
|
|
|
|
expect(transcribe).toHaveBeenCalledWith(audio)
|
|
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
expect(sendMessage).toHaveBeenCalledWith('hello hermes')
|
|
expect(dialogue.transcript.value).toBe('')
|
|
expect(dialogue.status.value).toBe('idle')
|
|
expect(dialogue.activeCaptureId.value).toBeNull()
|
|
})
|
|
|
|
it('does not persist backend STT audio blobs or transcripts to localStorage', async () => {
|
|
localStorage.clear()
|
|
const transcriptSentinel = 'backend transcript sentinel'
|
|
const audioSentinel = 'backend audio sentinel'
|
|
const sendMessage = vi.fn()
|
|
const audio = new Blob([audioSentinel], { type: 'audio/webm' })
|
|
const dialogue = useVoiceDialogue({
|
|
transcribe: vi.fn().mockResolvedValue({ text: transcriptSentinel }),
|
|
sendMessage,
|
|
})
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
await dialogue.transcribeAndSend(captureId, audio)
|
|
|
|
const stored = localStorageSnapshot()
|
|
expect(sendMessage).toHaveBeenCalledWith(transcriptSentinel)
|
|
expect(stored).not.toContain(transcriptSentinel)
|
|
expect(stored).not.toContain(audioSentinel)
|
|
})
|
|
|
|
it('drops stale transcript callbacks by captureId', async () => {
|
|
const sendMessage = vi.fn()
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'new command' }),
|
|
})
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const second = await dialogue.beginCapture()
|
|
|
|
await dialogue.commitTranscript(first.captureId, 'old command')
|
|
await dialogue.commitTranscript(second.captureId, 'new command')
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
expect(sendMessage).toHaveBeenCalledWith('new command')
|
|
})
|
|
|
|
it('does not persist browser transcripts to localStorage', async () => {
|
|
localStorage.clear()
|
|
const transcriptSentinel = 'browser transcript sentinel'
|
|
const sendMessage = vi.fn()
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
await dialogue.commitTranscript(captureId, transcriptSentinel)
|
|
|
|
const stored = localStorageSnapshot()
|
|
expect(sendMessage).toHaveBeenCalledWith(transcriptSentinel)
|
|
expect(stored).not.toContain(transcriptSentinel)
|
|
})
|
|
|
|
it('emits turn and capture events in sequence', async () => {
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage: vi.fn(),
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
await dialogue.commitTranscript(captureId, 'hello world')
|
|
|
|
const relevantTypes = dialogue.events.value
|
|
.map(event => event.type)
|
|
.filter(type => ['capture.started', 'transcript.done', 'turn.started', 'turn.ended'].includes(type))
|
|
|
|
expect(relevantTypes).toEqual([
|
|
'capture.started',
|
|
'transcript.done',
|
|
'turn.started',
|
|
'turn.ended',
|
|
])
|
|
})
|
|
|
|
it('retains only the last 50 events', () => {
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage: vi.fn(),
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
for (let index = 0; index < 55; index += 1) {
|
|
dialogue.markOutputStarted({ index })
|
|
}
|
|
|
|
expect(dialogue.events.value).toHaveLength(50)
|
|
const seqs = dialogue.events.value.map(event => event.seq)
|
|
expect(seqs).toEqual([...seqs].sort((left, right) => left - right))
|
|
expect(seqs[0]).toBe(seqs[seqs.length - 1] - 49)
|
|
})
|
|
|
|
it('drops stale transcribe results when capture changes before transcribe resolves', async () => {
|
|
const deferred = createDeferred<{ text: string }>()
|
|
const sendMessage = vi.fn()
|
|
const transcribe = vi.fn().mockReturnValue(deferred.promise)
|
|
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const pending = dialogue.transcribeAndSend(first.captureId, new Blob(['old']))
|
|
const second = await dialogue.beginCapture()
|
|
|
|
deferred.resolve({ text: 'stale transcript' })
|
|
await pending
|
|
|
|
expect(sendMessage).not.toHaveBeenCalled()
|
|
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
|
|
expect(dialogue.status.value).toBe('capturing')
|
|
})
|
|
|
|
it('does not emit stale turn completion when active capture changes during send', async () => {
|
|
const deferred = createDeferred<void>()
|
|
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const pendingCommit = dialogue.commitTranscript(first.captureId, 'old')
|
|
const firstTurnStarted = dialogue.events.value.find(
|
|
(event: { type: string; captureId?: string; turnId?: string | null }) =>
|
|
event.type === 'turn.started' && event.captureId === first.captureId,
|
|
)
|
|
expect(firstTurnStarted?.turnId).toBeTruthy()
|
|
|
|
const second = await dialogue.beginCapture()
|
|
|
|
deferred.resolve()
|
|
await pendingCommit
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1)
|
|
expect(sendMessage).toHaveBeenCalledWith('old')
|
|
expect(
|
|
dialogue.events.value.some(
|
|
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
|
|
),
|
|
).toBe(false)
|
|
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
|
|
expect(dialogue.status.value).toBe('capturing')
|
|
})
|
|
|
|
it('suppresses stale transcribe rejections after a new capture begins', async () => {
|
|
const deferred = createDeferred<{ text: string }>()
|
|
const sendMessage = vi.fn()
|
|
const transcribe = vi.fn().mockReturnValue(deferred.promise)
|
|
const dialogue = useVoiceDialogue({ transcribe, sendMessage })
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const pending = dialogue.transcribeAndSend(first.captureId, new Blob(['old']))
|
|
const second = await dialogue.beginCapture()
|
|
|
|
deferred.reject(new Error('transcribe failed'))
|
|
|
|
await expect(pending).resolves.toBeUndefined()
|
|
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
|
|
expect(dialogue.status.value).toBe('capturing')
|
|
expect(dialogue.error.value).toBeNull()
|
|
expect(
|
|
dialogue.events.value.some(
|
|
event => event.type === 'session.error' && event.captureId === first.captureId,
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('suppresses stale send rejections after a new capture begins', async () => {
|
|
const deferred = createDeferred<void>()
|
|
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const pending = dialogue.commitTranscript(first.captureId, 'hello')
|
|
const firstTurnStarted = dialogue.events.value.find(
|
|
(event: { type: string; captureId?: string; turnId?: string | null }) =>
|
|
event.type === 'turn.started' && event.captureId === first.captureId,
|
|
)
|
|
expect(firstTurnStarted?.turnId).toBeTruthy()
|
|
|
|
const second = await dialogue.beginCapture()
|
|
|
|
deferred.reject(new Error('send failed'))
|
|
|
|
await expect(pending).resolves.toBeUndefined()
|
|
expect(dialogue.activeCaptureId.value).toBe(second.captureId)
|
|
expect(dialogue.status.value).toBe('capturing')
|
|
expect(dialogue.error.value).toBeNull()
|
|
expect(
|
|
dialogue.events.value.some(
|
|
event => event.type === 'session.error' && event.captureId === first.captureId,
|
|
),
|
|
).toBe(false)
|
|
expect(
|
|
dialogue.events.value.some(
|
|
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('emits capture.cancelled before starting a superseding capture', async () => {
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage: vi.fn(),
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const second = await dialogue.beginCapture()
|
|
const cancelledIndex = dialogue.events.value.findIndex(
|
|
event => event.type === 'capture.cancelled' && event.captureId === first.captureId,
|
|
)
|
|
const startedIndex = dialogue.events.value.findIndex(
|
|
event => event.type === 'capture.started' && event.captureId === second.captureId,
|
|
)
|
|
|
|
expect(cancelledIndex).toBeGreaterThan(-1)
|
|
expect(startedIndex).toBeGreaterThan(-1)
|
|
expect(cancelledIndex).toBeLessThan(startedIndex)
|
|
})
|
|
|
|
it('emits terminal cancellation events for an active turn before starting a superseding capture', async () => {
|
|
const deferred = createDeferred<void>()
|
|
const sendMessage = vi.fn().mockReturnValue(deferred.promise)
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const first = await dialogue.beginCapture()
|
|
const pending = dialogue.commitTranscript(first.captureId, 'hello')
|
|
const firstTurnStarted = dialogue.events.value.find(
|
|
(event: { type: string; captureId?: string; turnId?: string | null }) =>
|
|
event.type === 'turn.started' && event.captureId === first.captureId,
|
|
)
|
|
expect(firstTurnStarted?.turnId).toBeTruthy()
|
|
|
|
const second = await dialogue.beginCapture()
|
|
const turnCancelledIndex = dialogue.events.value.findIndex(
|
|
event => event.type === 'turn.cancelled' && event.turnId === firstTurnStarted?.turnId,
|
|
)
|
|
const captureCancelledIndex = dialogue.events.value.findIndex(
|
|
event => event.type === 'capture.cancelled' && event.captureId === first.captureId,
|
|
)
|
|
const secondStartedIndex = dialogue.events.value.findIndex(
|
|
event => event.type === 'capture.started' && event.captureId === second.captureId,
|
|
)
|
|
|
|
expect(turnCancelledIndex).toBeGreaterThan(-1)
|
|
expect(captureCancelledIndex).toBeGreaterThan(-1)
|
|
expect(secondStartedIndex).toBeGreaterThan(-1)
|
|
expect(turnCancelledIndex).toBeLessThan(secondStartedIndex)
|
|
expect(captureCancelledIndex).toBeLessThan(secondStartedIndex)
|
|
|
|
deferred.resolve()
|
|
await pending
|
|
|
|
expect(
|
|
dialogue.events.value.some(
|
|
event => event.type === 'turn.ended' && event.turnId === firstTurnStarted?.turnId,
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it('empty transcript cancels capture and does not send', async () => {
|
|
const sendMessage = vi.fn()
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: '' }),
|
|
})
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
await dialogue.commitTranscript(captureId, ' \n ')
|
|
|
|
expect(sendMessage).not.toHaveBeenCalled()
|
|
expect(dialogue.activeCaptureId.value).toBeNull()
|
|
expect(dialogue.status.value).toBe('idle')
|
|
expect(dialogue.events.value.map(event => event.type)).toContain('capture.cancelled')
|
|
})
|
|
|
|
it('beginCapture calls stopOutputAudio for the barge-in boundary', async () => {
|
|
const stopOutputAudio = vi.fn()
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage: vi.fn(),
|
|
transcribe: vi.fn().mockResolvedValue({ text: '' }),
|
|
stopOutputAudio,
|
|
})
|
|
|
|
await dialogue.beginCapture()
|
|
|
|
expect(stopOutputAudio).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('continues capture when stopOutputAudio rejects', async () => {
|
|
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const stopOutputAudio = vi.fn().mockRejectedValue(new Error('stop failed'))
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage: vi.fn(),
|
|
transcribe: vi.fn().mockResolvedValue({ text: '' }),
|
|
stopOutputAudio,
|
|
})
|
|
|
|
const started = await dialogue.beginCapture()
|
|
|
|
expect(stopOutputAudio).toHaveBeenCalledOnce()
|
|
expect(started.captureId).toBeTruthy()
|
|
expect(dialogue.status.value).toBe('capturing')
|
|
expect(dialogue.activeCaptureId.value).toBe(started.captureId)
|
|
expect(dialogue.error.value).toBeNull()
|
|
expect(dialogue.events.value.at(-1)?.type).toBe('capture.started')
|
|
expect(consoleWarn).toHaveBeenCalledWith('[useVoiceDialogue] Failed to stop output audio before capture:', expect.any(Error))
|
|
})
|
|
|
|
it('sets error state and emits session.error when sendMessage fails', async () => {
|
|
const sendMessage = vi.fn().mockRejectedValue(new Error('send failed'))
|
|
const dialogue = useVoiceDialogue({
|
|
sendMessage,
|
|
transcribe: vi.fn().mockResolvedValue({ text: 'ignored' }),
|
|
})
|
|
|
|
const { captureId } = await dialogue.beginCapture()
|
|
|
|
await expect(dialogue.commitTranscript(captureId, 'hello')).rejects.toThrow('send failed')
|
|
expect(dialogue.status.value).toBe('error')
|
|
expect(dialogue.error.value?.message).toBe('send failed')
|
|
expect(dialogue.events.value.at(-1)?.type).toBe('session.error')
|
|
})
|
|
})
|