fix(desktop): stream voice replies from live message deltas
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
let value: unknown[] = []
|
||||
const listeners = new Set<(next: unknown[], old: unknown[]) => void>()
|
||||
|
||||
const messages = {
|
||||
get: () => value,
|
||||
listen: (listener: (next: unknown[], old: unknown[]) => void) => {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
set: (next: unknown[]) => {
|
||||
const old = value
|
||||
value = next
|
||||
listeners.forEach(listener => listener(next, old))
|
||||
},
|
||||
subscribe: (listener: (next: unknown[], old: unknown[]) => void) => {
|
||||
listeners.add(listener)
|
||||
listener(value, value)
|
||||
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
const useVoiceConversation = vi.fn(() => ({
|
||||
end: vi.fn(async () => undefined),
|
||||
level: 0,
|
||||
muted: false,
|
||||
start: vi.fn(async () => undefined),
|
||||
status: 'idle' as const,
|
||||
stopTurn: vi.fn(),
|
||||
toggleMute: vi.fn()
|
||||
}))
|
||||
|
||||
return { messages, useVoiceConversation }
|
||||
})
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
assistant: { thread: { readAloudFailed: 'read aloud failed' } },
|
||||
notifications: { voice: { sayStopToEnd: (phrase: string) => phrase } },
|
||||
settings: { config: { autosaveFailed: 'autosave failed' } }
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('../scope', () => ({
|
||||
useComposerScope: () => ({ $messages: mocks.messages }),
|
||||
useComposerSurfaceId: () => null
|
||||
}))
|
||||
vi.mock('./use-voice-conversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
|
||||
vi.mock('./use-voice-recorder', () => ({
|
||||
useVoiceRecorder: () => ({ dictate: vi.fn(), voiceActivityState: null, voiceStatus: 'idle' })
|
||||
}))
|
||||
vi.mock('./use-auto-speak-replies', () => ({ useAutoSpeakReplies: vi.fn() }))
|
||||
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
|
||||
vi.mock('@/lib/wake-indicator', () => ({
|
||||
clearWakeIndicator: vi.fn(),
|
||||
syncWakeIndicatorWithVoice: vi.fn(() => false)
|
||||
}))
|
||||
vi.mock('@/store/ambient', () => ({ ownsAmbientCue: vi.fn(async () => true) }))
|
||||
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
|
||||
vi.mock('@/store/wake-word', () => ({
|
||||
resumeWakeAfterVoice: vi.fn(async () => undefined),
|
||||
stopClientCapture: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
import { useComposerVoice } from './use-composer-voice'
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.messages.set([])
|
||||
mocks.useVoiceConversation.mockClear()
|
||||
})
|
||||
|
||||
test('re-renders the voice loop when an in-progress assistant delta lands', () => {
|
||||
renderHook(() =>
|
||||
useComposerVoice({
|
||||
busy: true,
|
||||
clearDraft: vi.fn(),
|
||||
disabled: false,
|
||||
focusInput: vi.fn(),
|
||||
insertText: vi.fn(),
|
||||
maxRecordingSeconds: 60,
|
||||
onSubmit: vi.fn(async () => true),
|
||||
onTranscribeAudio: vi.fn(async () => ''),
|
||||
sessionId: 'voice-session',
|
||||
target: 'main'
|
||||
})
|
||||
)
|
||||
|
||||
const callsBeforeDelta = mocks.useVoiceConversation.mock.calls.length
|
||||
|
||||
act(() => {
|
||||
mocks.messages.set([
|
||||
{
|
||||
id: 'assistant-stream',
|
||||
pending: true,
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'Natural selection is' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
expect(mocks.useVoiceConversation.mock.calls.length).toBeGreaterThan(callsBeforeDelta)
|
||||
})
|
||||
@@ -67,6 +67,11 @@ export function useComposerVoice({
|
||||
const { t } = useI18n()
|
||||
// A tile's composer speaks ITS transcript, not the primary chat's.
|
||||
const { $messages } = useComposerScope()
|
||||
// Voice conversation speech is driven by in-progress assistant deltas. Keep
|
||||
// this hook subscribed so its speech effect runs before busy flips false;
|
||||
// pendingTurnResponse still reads $messages.get() so the 150 ms feeder sees
|
||||
// every later delta instead of a stale render-time snapshot.
|
||||
useStore($messages)
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
// Engine selection is latched at conversation START (a Settings change
|
||||
// applies to the next conversation, never mid-call).
|
||||
|
||||
Reference in New Issue
Block a user