From e9e924ab82b900e5af875c13e66293b37a38adc6 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:14:15 +0000 Subject: [PATCH 1/3] fix: ground voice responses in Fast --- .changeset/ground-voice-in-fast.md | 5 ++ apps/docs/voice.mdx | 6 +-- .../FastSessionTranscript.client.test.tsx | 13 +---- .../[sessionId]/FastSessionTranscript.tsx | 7 ++- .../src/hooks/useLiveVoice.client.test.tsx | 51 ++++++++++++------- apps/web/src/hooks/useLiveVoice.ts | 20 +++----- apps/web/src/lib/server/voice.test.ts | 17 +++++-- apps/web/src/lib/server/voice.ts | 12 ++--- .../web/src/trpc/commands/voice/index.test.ts | 5 +- apps/web/src/trpc/commands/voice/index.ts | 12 ++--- 10 files changed, 78 insertions(+), 70 deletions(-) create mode 100644 .changeset/ground-voice-in-fast.md diff --git a/.changeset/ground-voice-in-fast.md b/.changeset/ground-voice-in-fast.md new file mode 100644 index 0000000000..982fe40eda --- /dev/null +++ b/.changeset/ground-voice-in-fast.md @@ -0,0 +1,5 @@ +--- +"@roomote/web": patch +--- + +Route every voice utterance through Fast so product answers use the Session's tools, context, and safeguards instead of unverified direct voice output. diff --git a/apps/docs/voice.mdx b/apps/docs/voice.mdx index 24fe06d183..4e2a183638 100644 --- a/apps/docs/voice.mdx +++ b/apps/docs/voice.mdx @@ -33,9 +33,9 @@ and receives only the negotiated session answer; it never receives the API key. confirms the call is open; a falling tone marks the end. A **Call started** marker appears in the Session. 3. Talk to Roomote the way you would on a phone call. It acknowledges each - request in a few words, hands the work to the Fast Session, and reports the - result out loud when it lands. Greetings, thanks, and small talk are - answered directly without starting Fast work. + utterance in a few words, hands it to the Fast Session, and reports the + response out loud when it lands. Every utterance is sent to the Fast Session, + where the selected model, tools, context, and safeguards handle the response. 4. Speak at any time to interrupt. Roomote keeps listening while it speaks, and follow-ups go back through the same Fast Session. You can also type in the composer during the call. diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 232e6dccf3..b56c3ee134 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -70,7 +70,6 @@ const { onUtterance: undefined as | ((text: string, delegationId: string | null) => void) | undefined, - onHeardTurn: undefined as ((text: string) => void) | undefined, onSpokenTurn: undefined as ((text: string) => void) | undefined, onHeardTurnDelta: undefined as ((text: string) => void) | undefined, onSpokenTurnDelta: undefined as ((text: string) => void) | undefined, @@ -80,19 +79,16 @@ const { vi.mock('@/hooks/useLiveVoice', () => ({ useLiveVoice: ({ onUtterance, - onHeardTurn, onSpokenTurn, onHeardTurnDelta, onSpokenTurnDelta, }: { onUtterance: (text: string, delegationId: string | null) => void; - onHeardTurn?: (text: string) => void; onSpokenTurn?: (text: string) => void; onHeardTurnDelta?: (text: string) => void; onSpokenTurnDelta?: (text: string) => void; }) => { liveVoiceState.onUtterance = onUtterance; - liveVoiceState.onHeardTurn = onHeardTurn; liveVoiceState.onSpokenTurn = onSpokenTurn; liveVoiceState.onHeardTurnDelta = onHeardTurnDelta; liveVoiceState.onSpokenTurnDelta = onSpokenTurnDelta; @@ -297,7 +293,6 @@ beforeEach(() => { recordVoiceCallEventMutate.mockResolvedValue({ eventId: 'voice-call:1' }); liveVoiceState.startedAt = null; liveVoiceState.deliveringUtterances = 0; - liveVoiceState.onHeardTurn = undefined; liveVoiceState.onSpokenTurn = undefined; liveVoiceState.active = false; liveVoiceState.status = 'idle'; @@ -2515,7 +2510,7 @@ describe('FastSessionTranscript', () => { ); }); - it('transcribes the call into the Session: markers, heard turns, and spoken turns', async () => { + it('transcribes the call into the Session: markers and spoken turns', async () => { voiceStatusQuery.mockResolvedValue({ enabled: true }); const transcript = () => ( { ); act(() => { - liveVoiceState.onHeardTurn?.('Hi Roomote, how is it going'); liveVoiceState.onSpokenTurn?.('Good, thanks. What can I do for you?'); }); - expect(recordVoiceTurnMutate).toHaveBeenCalledWith({ - sessionId: 'session-1', - role: 'user', - text: 'Hi Roomote, how is it going', - }); expect(recordVoiceTurnMutate).toHaveBeenCalledWith({ sessionId: 'session-1', role: 'assistant', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index b589dbdf47..4fd3b14085 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -957,7 +957,6 @@ export function FastSessionTranscript({ const requestInFlightRef = useRef(false); const liveVoice = useLiveVoice({ onUtterance: enqueueVoiceUtterance, - onHeardTurn: (text) => recordVoiceTurnRef.current('user', text), onSpokenTurn: (text) => { if (requestInFlightRef.current) { heldSpokenTurnsRef.current.push(text); @@ -1093,9 +1092,9 @@ export function FastSessionTranscript({ } }, [messages, streamMessages, liveVoiceActive]); - // The call is transcribed into the Session: what the person said when the - // voice answered directly, what the voice said, and where the call started - // and ended. Delegated requests are recorded by the Fast turn they start. + // The call is transcribed into the Session: Fast turns record what the + // person said, this path records what the voice said, and call events mark + // where the conversation started and ended. const recordVoiceTurn = useCallback( (role: 'user' | 'assistant', text: string) => { // The finished words stay on screen until their persisted row arrives. diff --git a/apps/web/src/hooks/useLiveVoice.client.test.tsx b/apps/web/src/hooks/useLiveVoice.client.test.tsx index 13d27b3324..79191630ef 100644 --- a/apps/web/src/hooks/useLiveVoice.client.test.tsx +++ b/apps/web/src/hooks/useLiveVoice.client.test.tsx @@ -295,40 +295,44 @@ describe('useLiveVoice', () => { expect(playVoiceCue).not.toHaveBeenCalled(); }); - it('records small talk GPT-Live handled itself as a heard turn, and what GPT-Live said as a spoken turn', async () => { + it('falls back to Fast once and ignores a late delegation for that utterance', async () => { const onUtterance = vi.fn(); - const onHeardTurn = vi.fn(); const onSpokenTurn = vi.fn(); const { result } = renderHook(() => - useLiveVoice({ onUtterance, onHeardTurn, onSpokenTurn }), + useLiveVoice({ onUtterance, onSpokenTurn }), ); await act(async () => result.current.start()); act(() => { FakePeer.instance.channel.emit({ type: 'session.input_transcript.delta', - delta: 'Thanks, that ', + delta: 'I want to talk about the browser, ', start_ms: 0, end_ms: 300, }); vi.advanceTimersByTime(1_000); FakePeer.instance.channel.emit({ type: 'session.input_transcript.delta', - delta: 'looks right', + delta: 'and how people might do more with it', start_ms: 300, end_ms: 600, }); vi.advanceTimersByTime(1_499); }); - expect(onHeardTurn).not.toHaveBeenCalled(); + expect(onUtterance).not.toHaveBeenCalled(); - act(() => { + await act(async () => { vi.advanceTimersByTime(1); }); - expect(onHeardTurn).toHaveBeenCalledWith('Thanks, that looks right'); - expect(onUtterance).not.toHaveBeenCalled(); + expect(cleanTranscriptMutate).toHaveBeenCalledWith({ + text: 'I want to talk about the browser, and how people might do more with it', + }); + expect(onUtterance).toHaveBeenCalledWith( + 'I want to talk about the browser, and how people might do more with it.', + null, + ); - // GPT-Live answers on its own; its words are recorded once it goes quiet. + // Direct Live output is still recorded, but it is not a second request. act(() => { FakePeer.instance.channel.emit({ type: 'session.output_transcript.delta', @@ -342,14 +346,17 @@ describe('useLiveVoice', () => { }); expect(onSpokenTurn).toHaveBeenCalledWith('Glad to hear it.'); - // A delegation that shows up right after a silence flush belongs to that - // utterance and must not be held for the next one. + // A delegation that arrives after fallback must not submit the utterance + // again or get attached to the next one. act(() => { FakePeer.instance.channel.emit({ type: 'session.delegation.created', delegation: { id: 'item_late', target: 'client' }, }); + vi.advanceTimersByTime(250); }); + expect(onUtterance).toHaveBeenCalledTimes(1); + act(() => { FakePeer.instance.channel.emit({ type: 'session.input_transcript.delta', @@ -357,10 +364,18 @@ describe('useLiveVoice', () => { start_ms: 5_000, end_ms: 5_400, }); - vi.advanceTimersByTime(1_500); + FakePeer.instance.channel.emit({ + type: 'session.delegation.created', + delegation: { id: 'item_next', target: 'client' }, + }); + vi.advanceTimersByTime(250); }); - expect(onHeardTurn).toHaveBeenLastCalledWith('Now check the build'); - expect(onUtterance).not.toHaveBeenCalled(); + await act(async () => {}); + expect(onUtterance).toHaveBeenLastCalledWith( + 'Now check the build.', + 'item_next', + ); + expect(onUtterance).toHaveBeenCalledTimes(2); }); it('streams both sides of the call as they are spoken', async () => { @@ -409,10 +424,9 @@ describe('useLiveVoice', () => { it('drops sound annotations from what the person said', async () => { const onUtterance = vi.fn(); - const onHeardTurn = vi.fn(); const onHeardTurnDelta = vi.fn(); const { result } = renderHook(() => - useLiveVoice({ onUtterance, onHeardTurn, onHeardTurnDelta }), + useLiveVoice({ onUtterance, onHeardTurnDelta }), ); await act(async () => result.current.start()); @@ -444,6 +458,7 @@ describe('useLiveVoice', () => { }); // Annotation-only speech is not a turn at all. + onUtterance.mockClear(); act(() => { FakePeer.instance.channel.emit({ type: 'session.input_transcript.delta', @@ -453,7 +468,7 @@ describe('useLiveVoice', () => { }); vi.advanceTimersByTime(1_500); }); - expect(onHeardTurn).not.toHaveBeenCalled(); + expect(onUtterance).not.toHaveBeenCalled(); }); it('flushes the spoken turn when the person starts talking again', async () => { diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts index de620ec146..83c4fdb2f9 100644 --- a/apps/web/src/hooks/useLiveVoice.ts +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -43,12 +43,6 @@ interface UseLiveVoiceOptions { * speaking a turn. This is the spoken record the Session persists. */ onSpokenTurn?: (text: string) => void; - /** - * Called with the raw transcript of what the person said each time GPT-Live - * handles it without delegating (small talk), so the Session still records - * it. Delegated utterances reach the Session through `onUtterance`. - */ - onHeardTurn?: (text: string) => void; /** Called with GPT-Live's words so far while it is speaking a turn. */ onSpokenTurnDelta?: (text: string) => void; /** Called with the person's words so far while they are speaking. */ @@ -119,7 +113,6 @@ async function waitForIceGathering(peer: RTCPeerConnection): Promise { export function useLiveVoice({ onUtterance, onSpokenTurn, - onHeardTurn, onSpokenTurnDelta, onHeardTurnDelta, disabled = false, @@ -134,8 +127,6 @@ export function useLiveVoice({ const [deliveringUtterances, setDeliveringUtterances] = useState(0); const onSpokenTurnRef = useRef(onSpokenTurn); onSpokenTurnRef.current = onSpokenTurn; - const onHeardTurnRef = useRef(onHeardTurn); - onHeardTurnRef.current = onHeardTurn; const onSpokenTurnDeltaRef = useRef(onSpokenTurnDelta); onSpokenTurnDeltaRef.current = onSpokenTurnDelta; const onHeardTurnDeltaRef = useRef(onHeardTurnDelta); @@ -242,9 +233,10 @@ export function useLiveVoice({ ); }, [flushDelegation]); - // Speech GPT-Live handles itself (small talk) never produces a delegation. - // Once the person has been quiet for a moment, record what they said so the - // Session transcript stays the complete record of the call. + // A missed or delayed GPT-Live delegation must not bypass Fast. Once the + // person has been quiet, submit the utterance without a delegation id. The + // cleared input and stale-delegation window keep a late event from sending + // the same utterance twice. const scheduleSilenceFlush = useCallback(() => { clearSilenceTimer(); silenceTimerRef.current = window.setTimeout(() => { @@ -253,9 +245,9 @@ export function useLiveVoice({ if (pendingDelegationsRef.current.length > 0) return; inputTranscriptRef.current = ''; lastSilenceFlushAtRef.current = Date.now(); - if (utterance) onHeardTurnRef.current?.(utterance); + if (utterance) deliverUtterance(utterance, null); }, UTTERANCE_SILENCE_FLUSH_MS); - }, [clearSilenceTimer]); + }, [clearSilenceTimer, deliverUtterance]); const handleServerEvent = useCallback( (raw: string) => { diff --git a/apps/web/src/lib/server/voice.test.ts b/apps/web/src/lib/server/voice.test.ts index 62fdd61ee0..0d96c472cc 100644 --- a/apps/web/src/lib/server/voice.test.ts +++ b/apps/web/src/lib/server/voice.test.ts @@ -103,10 +103,21 @@ describe('createVoiceLiveSession', () => { expect(body.session.instructions).toContain( 'Integrations the backend can use: GitHub.', ); - // The voice acknowledges, delegates real work, and reports results - // faithfully in its own words. + // The voice acknowledges, delegates every utterance, and reports results + // faithfully without originating answers or claims of inspection. expect(body.session.instructions).toContain('Backchannel policy'); - expect(body.session.instructions).toContain('Delegate to the backend when'); + expect(body.session.instructions).toContain( + 'Delegate every complete utterance to the backend', + ); + expect(body.session.instructions).toContain( + 'Never answer, explain, clarify, offer an opinion, or state a fact yourself', + ); + expect(body.session.instructions).toContain( + 'Never claim that you checked a source', + ); + expect(body.session.instructions).not.toContain( + 'Do not delegate to the backend when', + ); expect(body.session.instructions).toContain( 'keep every number, name, path, and link label exactly as given', ); diff --git a/apps/web/src/lib/server/voice.ts b/apps/web/src/lib/server/voice.ts index ed28bc1f40..c8ec194f69 100644 --- a/apps/web/src/lib/server/voice.ts +++ b/apps/web/src/lib/server/voice.ts @@ -148,7 +148,7 @@ The person will mostly talk about their code repositories, pull requests, issues ${formatVoiceWorkspaceContext(context)} -Backchannel policy: Acknowledge each request in a few words right away ("Sure.", "I'll check.") and then wait for the backend. Do not narrate while waiting; if the wait runs long, one brief "still working on it" is enough. +Backchannel policy: Acknowledge each utterance in a few words right away ("Sure.", "I'll check.") and then wait for the backend. Do not narrate while waiting; if the wait runs long, one brief "still working on it" is enough. Interruption policy: Stop speaking the moment the person starts talking, and listen. @@ -156,13 +156,9 @@ Delegation policy: Backend tools: - The backend is the Roomote Fast session: it reads and changes the repositories above, launches coding tasks in those environments, calls the listed integrations, reasons carefully, and returns results for you to report. -Delegate to the backend when: -- The person asks about or for anything involving code, repositories, pull requests, issues, tasks, tools, data, or facts about their work. Anything you would have to guess at, delegate. -- The person corrects, refines, or follows up on earlier work. - -Do not delegate to the backend when: -- The person is only greeting you, thanking you, reacting ("cool", "nice"), or making small talk. Answer briefly yourself. -- You need a one-line clarification to understand what they mean before the backend could act. +- Delegate every complete utterance to the backend, including greetings, thanks, reactions, small talk, corrections, follow-ups, and requests that need clarification. +- Your only self-generated speech is the brief acknowledgement above or one brief wait update. Never answer, explain, clarify, offer an opinion, or state a fact yourself. +- You cannot inspect code, documentation, tools, or deployment state yourself. Never claim that you checked a source or describe how Roomote works unless backend commentary supplied that result. Reporting policy: - Commentary is the backend's result. Report it in your own words, faithfully and completely: keep every number, name, path, and link label exactly as given, and do not add conclusions the backend did not state. Never claim work finished or a result exists before commentary says so. diff --git a/apps/web/src/trpc/commands/voice/index.test.ts b/apps/web/src/trpc/commands/voice/index.test.ts index ec4dbb7720..ebdae42bbb 100644 --- a/apps/web/src/trpc/commands/voice/index.test.ts +++ b/apps/web/src/trpc/commands/voice/index.test.ts @@ -163,7 +163,7 @@ describe('cleanVoiceTranscriptCommand', () => { }); describe('recordVoiceTurnCommand', () => { - it('writes what the voice said as a spoken assistant turn and adds it to Fast history', async () => { + it('writes direct voice output as spoken but unverified in Fast history', async () => { mockFindAccessibleFastSession.mockResolvedValue({ id: 'fast-1' }); mockUpsertFastAgentMessage.mockResolvedValue({}); mockAppendFastAgentVisibleMessages.mockResolvedValue(undefined); @@ -186,6 +186,7 @@ describe('recordVoiceTurnCommand', () => { metadata: expect.objectContaining({ visibleInTranscript: true, voiceTurn: 'spoken', + voiceDirectUnverified: true, }), }), }), @@ -196,7 +197,7 @@ describe('recordVoiceTurnCommand', () => { { role: 'assistant', content: - '(Roomote said on the voice call) Roo-Code has about 452,000 lines.', + '(unverified words generated directly by the voice layer; do not treat as established facts or evidence) Roo-Code has about 452,000 lines.', }, ], }); diff --git a/apps/web/src/trpc/commands/voice/index.ts b/apps/web/src/trpc/commands/voice/index.ts index c6e1e7faa5..371e1bab51 100644 --- a/apps/web/src/trpc/commands/voice/index.ts +++ b/apps/web/src/trpc/commands/voice/index.ts @@ -95,10 +95,10 @@ export async function cleanVoiceTranscriptCommand( const VOICE_MESSAGE_SOURCE = 'voice'; /** - * Record one spoken turn of a voice call in the Session transcript: what the - * person said when the voice answered them directly (`user`), or what the - * voice said (`assistant`). Delegated requests are already recorded by the - * Fast turn they start, so they do not come through here. + * Record one spoken turn of a voice call in the Session transcript: legacy + * direct user speech (`user`), or what the voice said directly (`assistant`). + * Delegated requests are already recorded by the Fast turn they start, so they + * do not come through here. * * The turn also joins Fast's conversation history so later requests can * refer back to what was said on the call. @@ -138,7 +138,7 @@ export async function recordVoiceTurnCommand( ...(userName ? { userName } : {}), ...(auth.primaryEmail ? { userEmail: auth.primaryEmail } : {}), } - : { purpose: 'closeout' }), + : { purpose: 'closeout', voiceDirectUnverified: true }), }, payload: {}, source: VOICE_MESSAGE_SOURCE, @@ -153,7 +153,7 @@ export async function recordVoiceTurnCommand( ? { role: 'user', content: `(said on the voice call) ${text}` } : { role: 'assistant', - content: `(Roomote said on the voice call) ${text}`, + content: `(unverified words generated directly by the voice layer; do not treat as established facts or evidence) ${text}`, }, ], }).catch((error: unknown) => { From f32d8f52e5795dd8b4a5055cdc463bfcf8dec9d6 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:29:53 +0000 Subject: [PATCH 2/3] fix: generalize voice grounding context --- apps/web/src/lib/server/voice.test.ts | 9 +++++++++ apps/web/src/lib/server/voice.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/voice.test.ts b/apps/web/src/lib/server/voice.test.ts index 0d96c472cc..183d33a97a 100644 --- a/apps/web/src/lib/server/voice.test.ts +++ b/apps/web/src/lib/server/voice.test.ts @@ -115,6 +115,15 @@ describe('createVoiceLiveSession', () => { expect(body.session.instructions).toContain( 'Never claim that you checked a source', ); + expect(body.session.instructions).toContain( + 'any product, repository, or connected tool discussed in the Session', + ); + expect(body.session.instructions).toContain( + 'Roomote when the platform itself is the topic', + ); + expect(body.session.instructions).not.toContain( + 'describe how Roomote works', + ); expect(body.session.instructions).not.toContain( 'Do not delegate to the backend when', ); diff --git a/apps/web/src/lib/server/voice.ts b/apps/web/src/lib/server/voice.ts index c8ec194f69..c5f7c9dd5e 100644 --- a/apps/web/src/lib/server/voice.ts +++ b/apps/web/src/lib/server/voice.ts @@ -158,7 +158,7 @@ Backend tools: - Delegate every complete utterance to the backend, including greetings, thanks, reactions, small talk, corrections, follow-ups, and requests that need clarification. - Your only self-generated speech is the brief acknowledgement above or one brief wait update. Never answer, explain, clarify, offer an opinion, or state a fact yourself. -- You cannot inspect code, documentation, tools, or deployment state yourself. Never claim that you checked a source or describe how Roomote works unless backend commentary supplied that result. +- You cannot inspect code, documentation, tools, or deployment state yourself. Never claim that you checked a source or state how any product, repository, or connected tool discussed in the Session works unless backend commentary supplied that result. This includes Roomote when the platform itself is the topic. Reporting policy: - Commentary is the backend's result. Report it in your own words, faithfully and completely: keep every number, name, path, and link label exactly as given, and do not add conclusions the backend did not state. Never claim work finished or a result exists before commentary says so. From 20a41b6d205484e52beb8509227943e3c6ce9014 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:38:43 +0000 Subject: [PATCH 3/3] fix: isolate late voice delegations --- .../src/hooks/useLiveVoice.client.test.tsx | 88 ++++++++++++++----- apps/web/src/hooks/useLiveVoice.ts | 32 +++---- 2 files changed, 78 insertions(+), 42 deletions(-) diff --git a/apps/web/src/hooks/useLiveVoice.client.test.tsx b/apps/web/src/hooks/useLiveVoice.client.test.tsx index 79191630ef..75249694c9 100644 --- a/apps/web/src/hooks/useLiveVoice.client.test.tsx +++ b/apps/web/src/hooks/useLiveVoice.client.test.tsx @@ -295,12 +295,9 @@ describe('useLiveVoice', () => { expect(playVoiceCue).not.toHaveBeenCalled(); }); - it('falls back to Fast once and ignores a late delegation for that utterance', async () => { + it('stays in fallback-only mode after a missed delegation', async () => { const onUtterance = vi.fn(); - const onSpokenTurn = vi.fn(); - const { result } = renderHook(() => - useLiveVoice({ onUtterance, onSpokenTurn }), - ); + const { result } = renderHook(() => useLiveVoice({ onUtterance })); await act(async () => result.current.start()); act(() => { @@ -332,23 +329,11 @@ describe('useLiveVoice', () => { null, ); - // Direct Live output is still recorded, but it is not a second request. - act(() => { - FakePeer.instance.channel.emit({ - type: 'session.output_transcript.delta', - delta: 'Glad to ', - }); - FakePeer.instance.channel.emit({ - type: 'session.output_transcript.delta', - delta: 'hear it.', - }); - vi.advanceTimersByTime(1_200); - }); - expect(onSpokenTurn).toHaveBeenCalledWith('Glad to hear it.'); - - // A delegation that arrives after fallback must not submit the utterance - // again or get attached to the next one. + // Neither A's late delegation nor B's own delegation can be correlated + // after fallback, even well beyond the old three-second window. Both are + // ignored and B uses the same safe path. act(() => { + vi.advanceTimersByTime(10_000); FakePeer.instance.channel.emit({ type: 'session.delegation.created', delegation: { id: 'item_late', target: 'client' }, @@ -368,13 +353,70 @@ describe('useLiveVoice', () => { type: 'session.delegation.created', delegation: { id: 'item_next', target: 'client' }, }); + vi.advanceTimersByTime(1_500); + }); + await act(async () => {}); + expect(onUtterance).toHaveBeenLastCalledWith('Now check the build.', null); + expect(onUtterance).toHaveBeenCalledTimes(2); + + act(() => result.current.stop()); + await act(async () => result.current.start()); + act(() => { + FakePeer.instance.channel.emit({ + type: 'session.delegation.created', + delegation: { id: 'item_new_call', target: 'client' }, + }); + FakePeer.instance.channel.emit({ + type: 'session.input_transcript.delta', + delta: 'Fresh call', + }); vi.advanceTimersByTime(250); }); await act(async () => {}); expect(onUtterance).toHaveBeenLastCalledWith( - 'Now check the build.', - 'item_next', + 'Fresh call.', + 'item_new_call', ); + expect(onUtterance).toHaveBeenCalledTimes(3); + }); + + it('does not attach A late delegation after utterance B has started', async () => { + const onUtterance = vi.fn(); + const { result } = renderHook(() => useLiveVoice({ onUtterance })); + + await act(async () => result.current.start()); + act(() => { + FakePeer.instance.channel.emit({ + type: 'session.input_transcript.delta', + delta: 'First request', + }); + vi.advanceTimersByTime(1_500); + }); + await act(async () => {}); + expect(onUtterance).toHaveBeenCalledWith('First request.', null); + + act(() => { + FakePeer.instance.channel.emit({ + type: 'session.input_transcript.delta', + delta: 'Second ', + }); + FakePeer.instance.channel.emit({ + type: 'session.delegation.created', + delegation: { id: 'item_first_late', target: 'client' }, + }); + FakePeer.instance.channel.emit({ + type: 'session.input_transcript.delta', + delta: 'request', + }); + vi.advanceTimersByTime(250); + }); + expect(onUtterance).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(1_250); + }); + await act(async () => {}); + expect(onUtterance).toHaveBeenLastCalledWith('Second request.', null); expect(onUtterance).toHaveBeenCalledTimes(2); }); diff --git a/apps/web/src/hooks/useLiveVoice.ts b/apps/web/src/hooks/useLiveVoice.ts index 83c4fdb2f9..62506ccd30 100644 --- a/apps/web/src/hooks/useLiveVoice.ts +++ b/apps/web/src/hooks/useLiveVoice.ts @@ -13,13 +13,10 @@ import { const DELEGATION_TRANSCRIPT_SETTLE_MS = 250; /** - * Speech GPT-Live answers itself never produces a delegation. After this much - * silence with no delegation the utterance is recorded as a heard turn so the - * Session transcript still has it. + * Backstop a GPT-Live delegation that never arrives. After this much silence, + * the utterance is sent through Fast without a delegation id. */ const UTTERANCE_SILENCE_FLUSH_MS = 1_500; -/** A delegation this soon after a silence flush belongs to that utterance. */ -const STALE_DELEGATION_WINDOW_MS = 3_000; /** GPT-Live has finished a spoken turn once its transcript stops growing. */ const SPOKEN_TURN_SETTLE_MS = 1_200; const SESSION_START_TIMEOUT_MS = 15_000; @@ -155,7 +152,10 @@ export function useLiveVoice({ const pendingDelegationsRef = useRef([]); const delegationTimerRef = useRef(null); const silenceTimerRef = useRef(null); - const lastSilenceFlushAtRef = useRef(0); + // Delegations have no utterance identifier. After one is missed, accepting + // any later delegation could attach it to the wrong transcript, so the rest + // of this call uses the ordered silence fallback instead. + const fallbackOnlyRef = useRef(false); const speakingTimerRef = useRef(null); const deliveryChainRef = useRef>(Promise.resolve()); @@ -235,8 +235,8 @@ export function useLiveVoice({ // A missed or delayed GPT-Live delegation must not bypass Fast. Once the // person has been quiet, submit the utterance without a delegation id. The - // cleared input and stale-delegation window keep a late event from sending - // the same utterance twice. + // call then stays in fallback-only mode because later delegation events + // cannot be correlated safely with a specific utterance. const scheduleSilenceFlush = useCallback(() => { clearSilenceTimer(); silenceTimerRef.current = window.setTimeout(() => { @@ -244,8 +244,9 @@ export function useLiveVoice({ const utterance = stripVoiceAnnotations(inputTranscriptRef.current); if (pendingDelegationsRef.current.length > 0) return; inputTranscriptRef.current = ''; - lastSilenceFlushAtRef.current = Date.now(); - if (utterance) deliverUtterance(utterance, null); + if (!utterance) return; + fallbackOnlyRef.current = true; + deliverUtterance(utterance, null); }, UTTERANCE_SILENCE_FLUSH_MS); }, [clearSilenceTimer, deliverUtterance]); @@ -299,15 +300,7 @@ export function useLiveVoice({ break; case 'session.delegation.created': if (event.delegation?.target === 'client' && event.delegation.id) { - // A delegation arriving just after the silence flush already sent - // that utterance; attaching it to the next one would skew replies. - if ( - !inputTranscriptRef.current.trim() && - Date.now() - lastSilenceFlushAtRef.current < - STALE_DELEGATION_WINDOW_MS - ) { - break; - } + if (fallbackOnlyRef.current) break; pendingDelegationsRef.current.push(event.delegation.id); scheduleDelegationFlush(); } @@ -377,6 +370,7 @@ export function useLiveVoice({ inputTranscriptRef.current = ''; pendingDelegationsRef.current = []; + fallbackOnlyRef.current = false; setActive(false); setStatus('idle'); setStartedAt(null);