fix(desktop): stop the voice-live toasts leaking machine strings (#111987)
The session-end toast printed the raw wire reason (`connection_lost (127s)`,
`closed`) and a denied `getUserMedia` reached the toast as bare `DOMException`
text, while the recorder path already had friendly copy for those names.
- `liveEndedMessage` (new, exported for the tests) maps our own close reasons —
`connection_lost`, `closed`, plus a blank reason — to i18n copy; server-sent
reasons stay verbatim (unbounded, no redaction claim).
- `micError` is exported and reused on the live start path, so a mic
DOMException gets the recorder's copy; an unmapped DOMException name now falls
back to `microphoneStartFailed` instead of its raw text.
- The start catch maps DOMExceptions only: non-mic failures ('GPT-Live session
already started', 'Missing local SDP offer', API errors) keep their message.
New i18n keys: `notifications.voice.liveEndedConnectionLost` / `liveEndedClosed`
(en base + zh translation; the other locales fall back to en).
Tests: `use-voice-live-conversation.test.ts` drives the real toast store through
a fake transport — reason copy, blank/unknown reasons, `close_requested` filter,
mic DOMException names, and untouched non-mic failures.
This commit is contained in:
@@ -33,7 +33,11 @@ interface MicRecorderHandle {
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
/** Recorder + live-start mic failures → the same friendly copy: a DOMException
|
||||
* name is mapped, an unrecognized DOMException falls back to the generic start
|
||||
* copy, and anything else keeps its own message (non-mic failures must not be
|
||||
* mislabeled as microphone problems). */
|
||||
export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
const name = error instanceof DOMException ? error.name : ''
|
||||
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
@@ -52,6 +56,10 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
return new Error(copy.microphoneConstraintsUnsupported)
|
||||
}
|
||||
|
||||
if (error instanceof DOMException) {
|
||||
return new Error(copy.microphoneStartFailed)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -1,9 +1,190 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { chunkForCommentary, toLiveHistory } from '@/lib/voice-live'
|
||||
import { en } from '@/i18n/en'
|
||||
import { chunkForCommentary, toLiveHistory, type VoiceLiveHandlers } from '@/lib/voice-live'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
|
||||
import { delegationPrompt } from './use-voice-live-conversation'
|
||||
import { delegationPrompt, useVoiceLiveConversation } from './use-voice-live-conversation'
|
||||
|
||||
// Issue #111987: the live-voice toasts must not show machine strings — neither
|
||||
// the wire close reasons (`connection_lost`, `closed`) nor the raw
|
||||
// `DOMException` text a denied `getUserMedia` throws.
|
||||
//
|
||||
// The transport is the only seam in the live hook, so the fake session records
|
||||
// the handlers it registers (tests drive the close/error paths directly) and
|
||||
// lets `start` reject with exactly what the real `getUserMedia` would throw.
|
||||
const transport = vi.hoisted(() => ({ failure: null as unknown, handlers: [] as VoiceLiveHandlers[] }))
|
||||
|
||||
vi.mock('@/lib/voice-live', async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
...actual,
|
||||
VoiceLiveSession: class {
|
||||
close = vi.fn()
|
||||
instruct = vi.fn()
|
||||
setMuted = vi.fn()
|
||||
speak = vi.fn()
|
||||
think = vi.fn()
|
||||
|
||||
constructor(handlers: VoiceLiveHandlers) {
|
||||
transport.handlers.push(handlers)
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (transport.failure) {
|
||||
throw transport.failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const voice = en.notifications.voice
|
||||
|
||||
function mountLive() {
|
||||
return renderHook(() =>
|
||||
useVoiceLiveConversation({
|
||||
busy: false,
|
||||
consumePendingResponse: vi.fn(),
|
||||
enabled: true,
|
||||
onSubmit: vi.fn(),
|
||||
pendingResponse: () => null,
|
||||
seedHistory: () => []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Mount, start, and hand back the handlers the started session registered. */
|
||||
async function openSession(): Promise<VoiceLiveHandlers> {
|
||||
const hook = mountLive()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.start()
|
||||
})
|
||||
|
||||
return transport.handlers.at(-1) as VoiceLiveHandlers
|
||||
}
|
||||
|
||||
/** Start a session whose transport fails the way the real one can. */
|
||||
async function failedStart(failure: unknown): Promise<void> {
|
||||
transport.failure = failure
|
||||
const hook = mountLive()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.start()
|
||||
})
|
||||
}
|
||||
|
||||
function resetToasts() {
|
||||
clearNotifications()
|
||||
transport.failure = null
|
||||
transport.handlers.length = 0
|
||||
}
|
||||
|
||||
describe('Voice-live session-end toast copy (#111987)', () => {
|
||||
beforeEach(resetToasts)
|
||||
afterEach(cleanup)
|
||||
|
||||
it('names the lost connection in copy instead of the internal reason code', async () => {
|
||||
const handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('connection_lost', 127)
|
||||
})
|
||||
|
||||
const toast = $notifications.get()[0]
|
||||
|
||||
expect(toast.title).toBe(voice.liveEnded)
|
||||
expect(toast.message).toBe(`${voice.liveEndedConnectionLost} (127s)`)
|
||||
expect(toast.message).not.toContain('connection_lost')
|
||||
})
|
||||
|
||||
it('names the vendor-side close in copy instead of the "closed" fallback code', async () => {
|
||||
const handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('closed', null)
|
||||
})
|
||||
|
||||
const toast = $notifications.get()[0]
|
||||
|
||||
expect(toast.kind).toBe('warning')
|
||||
expect(toast.message).toBe(voice.liveEndedClosed)
|
||||
})
|
||||
|
||||
it('falls back to the ended copy when the wire reason is blank', async () => {
|
||||
const handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('', null)
|
||||
})
|
||||
|
||||
expect($notifications.get()[0].message).toBe(voice.liveEndedClosed)
|
||||
})
|
||||
|
||||
it('passes an unrecognized server-sent reason through verbatim (unbounded by contract)', async () => {
|
||||
const handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('quota_exhausted', 12)
|
||||
})
|
||||
|
||||
expect($notifications.get()[0].message).toBe('quota_exhausted (12s)')
|
||||
})
|
||||
|
||||
it('still drops close_requested (our own close) without a toast', async () => {
|
||||
const handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('close_requested', 30)
|
||||
})
|
||||
|
||||
expect($notifications.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Voice-live mic failure toast copy (#111987)', () => {
|
||||
beforeEach(resetToasts)
|
||||
afterEach(cleanup)
|
||||
|
||||
it('reuses the recorder mic copy for a getUserMedia DOMException', async () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['NotAllowedError', voice.microphonePermissionDenied],
|
||||
['NotFoundError', voice.noMicrophone],
|
||||
['NotReadableError', voice.microphoneInUse],
|
||||
['OverconstrainedError', voice.microphoneConstraintsUnsupported]
|
||||
]
|
||||
|
||||
for (const [name, copy] of cases) {
|
||||
await failedStart(
|
||||
new DOMException('The request is not allowed by the user agent or the platform in the current context.', name)
|
||||
)
|
||||
|
||||
const toast = $notifications.get()[0]
|
||||
|
||||
expect(toast.title).toBe(voice.couldNotStartSession)
|
||||
expect(toast.message).toBe(copy)
|
||||
expect(toast.message).not.toContain('user agent')
|
||||
|
||||
cleanup()
|
||||
resetToasts()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a non-mic start failure message untouched', async () => {
|
||||
for (const message of ['GPT-Live session already started', 'Missing local SDP offer']) {
|
||||
await failedStart(new Error(message))
|
||||
|
||||
expect($notifications.get()[0].message).toBe(message)
|
||||
|
||||
cleanup()
|
||||
resetToasts()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPT-Live delegation → Hermes turn', () => {
|
||||
it('sends the latest user words as the turn and the exchange as model-only context', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type LiveHistoryMessage, type LiveTranscriptFragment, VoiceLiveSession
|
||||
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { micError } from './use-mic-recorder'
|
||||
import type { ConversationStatus } from './use-voice-conversation'
|
||||
|
||||
/** How long an accepted delegation may sit before the gateway shows the turn running. */
|
||||
@@ -67,6 +68,29 @@ export function delegationPrompt(context: LiveTranscriptFragment[]): { context:
|
||||
return { context: transcript, prompt: prompt || transcript.slice(-400) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the session-end toast. `connection_lost` and `closed` are our own
|
||||
* machine reasons (`lib/voice-live.ts`) and get i18n copy; so does a blank
|
||||
* reason, which has no wording of its own. Any other reason is server-sent and
|
||||
* unbounded, so it passes through verbatim (issue #111987 — no redaction claim
|
||||
* for vendor strings).
|
||||
*/
|
||||
export function liveEndedMessage(
|
||||
reason: string,
|
||||
usageSeconds: null | number,
|
||||
copy: { liveEndedClosed: string; liveEndedConnectionLost: string }
|
||||
): string {
|
||||
let text = reason?.trim() ?? ''
|
||||
|
||||
if (text === 'connection_lost') {
|
||||
text = copy.liveEndedConnectionLost
|
||||
} else if (text === 'closed' || !text) {
|
||||
text = copy.liveEndedClosed
|
||||
}
|
||||
|
||||
return usageSeconds != null ? `${text} (${Math.round(usageSeconds)}s)` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-Live conversation engine — same public shape as `useVoiceConversation`
|
||||
* so the composer can mount either from `voice.voice_chat_mode`.
|
||||
@@ -252,7 +276,7 @@ export function useVoiceLiveConversation({
|
||||
if (reason !== 'close_requested') {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
message: usageSeconds != null ? `${reason} (${Math.round(usageSeconds)}s)` : reason,
|
||||
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
|
||||
title: voiceCopy.liveEnded
|
||||
})
|
||||
latest.current.onFatalError?.()
|
||||
@@ -330,19 +354,14 @@ export function useVoiceLiveConversation({
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, voiceCopy.couldNotStartSession)
|
||||
// Only a mic DOMException gets the recorder's copy: this catch also
|
||||
// takes non-mic start failures ('GPT-Live session already started',
|
||||
// 'Missing local SDP offer', API errors) — those keep their own message.
|
||||
notifyError(error instanceof DOMException ? micError(error, voiceCopy) : error, voiceCopy.couldNotStartSession)
|
||||
setStatus('idle')
|
||||
latest.current.onFatalError?.()
|
||||
}
|
||||
}, [
|
||||
end,
|
||||
refreshStatus,
|
||||
setDelegation,
|
||||
voiceCopy.couldNotStartSession,
|
||||
voiceCopy.liveDelegationFailed,
|
||||
voiceCopy.liveEnded,
|
||||
voiceCopy.liveError
|
||||
])
|
||||
}, [end, refreshStatus, setDelegation, voiceCopy])
|
||||
|
||||
// Drive the reply back into the voice: stream commentary as Hermes writes
|
||||
// it (sentence-chunked), quiet tool progress as thinking appends, and clear
|
||||
|
||||
@@ -278,6 +278,8 @@ export const en: Translations = {
|
||||
tryRecordingAgain: 'Try recording again.',
|
||||
unavailable: 'Voice unavailable',
|
||||
liveEnded: 'Live voice session ended',
|
||||
liveEndedConnectionLost: 'The live voice session lost its connection.',
|
||||
liveEndedClosed: 'The live voice session was closed by the service.',
|
||||
liveError: 'Live voice',
|
||||
liveDelegationFailed: 'Could not hand the request to Hermes',
|
||||
liveUnavailable: reason => `GPT-Live voice chat is not available: ${reason}. Using speech-to-text instead.`
|
||||
|
||||
@@ -322,6 +322,8 @@ export interface Translations {
|
||||
tryRecordingAgain: string
|
||||
unavailable: string
|
||||
liveEnded: string
|
||||
liveEndedConnectionLost: string
|
||||
liveEndedClosed: string
|
||||
liveError: string
|
||||
liveDelegationFailed: string
|
||||
liveUnavailable: (reason: string) => string
|
||||
|
||||
@@ -242,6 +242,8 @@ export const zh = defineLocale({
|
||||
tryRecordingAgain: '请再录一次。',
|
||||
unavailable: '语音不可用',
|
||||
liveEnded: '实时语音会话已结束',
|
||||
liveEndedConnectionLost: '实时语音会话连接已断开。',
|
||||
liveEndedClosed: '实时语音会话已被服务端关闭。',
|
||||
liveError: '实时语音',
|
||||
liveDelegationFailed: '无法将请求交给 Hermes',
|
||||
liveUnavailable: reason => `GPT-Live 语音聊天不可用:${reason}。已改用语音转文字。`
|
||||
|
||||
Reference in New Issue
Block a user