From ac4bbe1884d24c588a14739a8ec53f276fefde66 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 13:32:56 -0400 Subject: [PATCH 01/18] fix(voice): retain active transcript routes --- .../useVoiceConversationController.test.ts | 69 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 7 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index f0735ae53..7cab4133d 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -17,6 +17,11 @@ const voiceApiMocks = vi.hoisted(() => ({ const microphonePermissionMocks = vi.hoisted(() => ({ getStatus: vi.fn<() => Promise<"authorized" | "denied">>(), })); +const voiceStoreMocks = vi.hoisted(() => ({ + subscriber: undefined as + | ((event: Record) => void | Promise) + | undefined, +})); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: tauriWindowMocks.label }), @@ -39,6 +44,18 @@ vi.mock("../api/microphonePermission", () => ({ getMicrophonePermissionStatus: microphonePermissionMocks.getStatus, })); +vi.mock("../stores/voiceConversationStore", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../stores/voiceConversationStore") + >()), + subscribeToVoiceConversationEvents: ( + subscriber: (event: Record) => void | Promise, + ) => { + voiceStoreMocks.subscriber = subscriber; + return () => undefined; + }, +})); + import { canBindVoiceSendRoute, canReplaceActiveVoiceConversation, @@ -217,6 +234,58 @@ describe("voice transcript delivery coordination", () => { microphonePermissionMocks.getStatus.mockResolvedValue("authorized"); useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); }); + + it("delivers a queued transcript after its chat becomes temporarily ineligible", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ disabled }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + disabled, + }), + { initialProps: { disabled: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ disabled: true }); + await act(async () => { + await voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-1", + text: "keep this route", + revision: 1, + deliveryAttempts: 0, + }); + }); + + expect(onSend).toHaveBeenCalledWith( + "keep this route", + undefined, + undefined, + expect.objectContaining({ displayText: "keep this route" }), + ); + }); it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 658223c6f..6c29aa342 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -33,9 +33,8 @@ interface VoiceSendRoute { send: ChatInputSendHandler; } -// The backend conversation is process-wide, but voice input is intentionally -// foreground-chat scoped. The route remains available only while at least one -// view for its bound session is mounted. +// The backend conversation is process-wide, but voice input remains bound to +// the chat that started the active lifecycle until that lifecycle terminates. let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; const operationInFlightBySession = new Set(); @@ -677,8 +676,6 @@ export function useVoiceConversationController({ }); if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; - } else if (!routeIsValid && activeSendRoute?.sessionId === sessionId) { - activeSendRoute = null; } if (routeMount.drainPending) { const routeSessionId = activeSendRoute?.sessionId; From 9326f019bbb67455828f296591ec0274125d617e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 13:46:06 -0400 Subject: [PATCH 02/18] fix(voice): release invalid transcript routes --- .../useVoiceConversationController.test.ts | 46 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 5 ++ 2 files changed, 51 insertions(+) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 7cab4133d..54dd57d60 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -286,6 +286,52 @@ describe("voice transcript delivery coordination", () => { expect.objectContaining({ displayText: "keep this route" }), ); }); + + it("releases a retained transcript route when the chat becomes read-only", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ readOnly }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + readOnly, + }), + { initialProps: { readOnly: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ readOnly: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-read-only", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 6c29aa342..f41cb49f1 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -676,6 +676,11 @@ export function useVoiceConversationController({ }); if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; + } else if ( + (!enabled || !isGooseSession || readOnly) && + activeSendRoute?.sessionId === sessionId + ) { + activeSendRoute = null; } if (routeMount.drainPending) { const routeSessionId = activeSendRoute?.sessionId; From 336824d5142fe4838510fd894422c961ea946d22 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:03:43 -0400 Subject: [PATCH 03/18] fix(voice): release routes on admission blocks --- src/features/chat/ui/ChatView.tsx | 1 + .../useVoiceConversationController.test.ts | 47 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 21 ++++++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 4ac10a0bb..a7d643ea2 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -264,6 +264,7 @@ export function ChatView({ }); }, readOnly: Boolean(readOnlyStatus), + routeBlocked: admissionBlocked, disabled: admissionBlocked || controller.projectMetadataPending || diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 54dd57d60..02188f2b0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -332,6 +332,53 @@ describe("voice transcript delivery coordination", () => { ).rejects.toThrow("bound chat is unavailable"); expect(onSend).not.toHaveBeenCalled(); }); + + it("releases a retained transcript route when admission is blocked", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ routeBlocked: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-admission-blocked", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); + it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index f41cb49f1..9a3eefab8 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -593,6 +593,7 @@ export interface UseVoiceConversationControllerOptions { onPocketSetupRequired: () => void; readOnly?: boolean; disabled?: boolean; + routeBlocked?: boolean; } export function useVoiceConversationController({ @@ -606,6 +607,7 @@ export function useVoiceConversationController({ onPocketSetupRequired, readOnly = false, disabled = false, + routeBlocked = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -661,12 +663,14 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - const routeIsValid = canBindVoiceSendRoute({ - enabled, - isGooseSession, - readOnly, - disabled, - }); + const routeIsValid = + !routeBlocked && + canBindVoiceSendRoute({ + enabled, + isGooseSession, + readOnly, + disabled, + }); const activeVoiceSessionId = status.sessionId; const routeMount = resolveVoiceRouteMount({ routeIsValid, @@ -677,7 +681,7 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly) && + (!enabled || !isGooseSession || readOnly || routeBlocked) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; @@ -699,6 +703,7 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, + routeBlocked, sessionId, status.sessionId, ]); @@ -707,6 +712,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || + routeBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -734,6 +740,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + routeBlocked, sessionId, status.lifecycle, status.sessionId, From 939cdf5b444d761991b2b0139549ca28118a0113 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:21:17 -0400 Subject: [PATCH 04/18] fix(voice): discard speech blocked by admission --- .../useVoiceConversationController.test.ts | 27 ++++++++++++++++--- .../hooks/useVoiceConversationController.ts | 23 ++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 02188f2b0..21394344c 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -333,7 +333,7 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("releases a retained transcript route when admission is blocked", async () => { + it("discards transcripts for a lifecycle after admission is blocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ status: { @@ -349,7 +349,7 @@ describe("voice transcript delivery coordination", () => { hydrated: true, init: vi.fn().mockResolvedValue(undefined), }); - const { rerender } = renderHook( + const { rerender, unmount } = renderHook( ({ routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", @@ -375,8 +375,29 @@ describe("voice transcript delivery coordination", () => { revision: 1, deliveryAttempts: 0, }), - ).rejects.toThrow("bound chat is unavailable"); + ).resolves.toBeUndefined(); expect(onSend).not.toHaveBeenCalled(); + + rerender({ routeBlocked: false }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-after-admission-block", + text: "still do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + expect(onSend).not.toHaveBeenCalled(); + + act(() => { + useVoiceConversationStore.setState((state) => ({ + status: { ...state.status, lifecycle: "stopped", sessionId: null }, + })); + }); + unmount(); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 9a3eefab8..a254f2afa 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -36,6 +36,7 @@ interface VoiceSendRoute { // The backend conversation is process-wide, but voice input remains bound to // the chat that started the active lifecycle until that lifecycle terminates. let activeSendRoute: VoiceSendRoute | null = null; +const blockedSendRouteSessions = new Set(); let deliveryInitialized = false; const operationInFlightBySession = new Set(); let replacementOperationInFlight = false; @@ -446,6 +447,11 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { + // Admission blocks invalidate speech captured for the active lifecycle. + // Resolve successfully so the native queue acknowledges it instead of + // replaying it if the block later clears. + if (blockedSendRouteSessions.has(event.sessionId)) return; + const route = activeSendRoute; if (!route || route.sessionId !== event.sessionId) { const message = @@ -663,8 +669,14 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); + if (status.sessionId !== sessionId) { + blockedSendRouteSessions.delete(sessionId); + } else if (routeBlocked) { + blockedSendRouteSessions.add(sessionId); + } + const routeDiscardedForLifecycle = blockedSendRouteSessions.has(sessionId); const routeIsValid = - !routeBlocked && + !routeDiscardedForLifecycle && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -686,8 +698,10 @@ export function useVoiceConversationController({ ) { activeSendRoute = null; } - if (routeMount.drainPending) { - const routeSessionId = activeSendRoute?.sessionId; + if (routeMount.drainPending || routeDiscardedForLifecycle) { + const routeSessionId = routeDiscardedForLifecycle + ? sessionId + : activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( routeSessionId, @@ -712,7 +726,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - routeBlocked || + blockedSendRouteSessions.has(sessionId) || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -740,7 +754,6 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, - routeBlocked, sessionId, status.lifecycle, status.sessionId, From b137a9cb9fae7a7237fa3c0fe412428a14fa87f2 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:50:14 -0400 Subject: [PATCH 05/18] fix(voice): defer transcripts during admission --- .../useVoiceConversationController.test.ts | 22 ++++++------ .../hooks/useVoiceConversationController.ts | 34 +++++++++++-------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 21394344c..fa084a9e4 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -333,7 +333,7 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("discards transcripts for a lifecycle after admission is blocked", async () => { + it("defers transcripts until admission is unblocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ status: { @@ -349,7 +349,7 @@ describe("voice transcript delivery coordination", () => { hydrated: true, init: vi.fn().mockResolvedValue(undefined), }); - const { rerender, unmount } = renderHook( + const { rerender } = renderHook( ({ routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", @@ -375,7 +375,7 @@ describe("voice transcript delivery coordination", () => { revision: 1, deliveryAttempts: 0, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("waiting for its bound chat"); expect(onSend).not.toHaveBeenCalled(); rerender({ routeBlocked: false }); @@ -385,19 +385,17 @@ describe("voice transcript delivery coordination", () => { sessionId: "session-1", lifecycleId: "lifecycle-1", id: "utterance-after-admission-block", - text: "still do not deliver", + text: "deliver after unblock", revision: 1, deliveryAttempts: 0, }), ).resolves.toBeUndefined(); - expect(onSend).not.toHaveBeenCalled(); - - act(() => { - useVoiceConversationStore.setState((state) => ({ - status: { ...state.status, lifecycle: "stopped", sessionId: null }, - })); - }); - unmount(); + expect(onSend).toHaveBeenCalledWith( + "deliver after unblock", + undefined, + undefined, + expect.objectContaining({ displayText: "deliver after unblock" }), + ); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index a254f2afa..4c778f804 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -447,10 +447,11 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { - // Admission blocks invalidate speech captured for the active lifecycle. - // Resolve successfully so the native queue acknowledges it instead of - // replaying it if the block later clears. - if (blockedSendRouteSessions.has(event.sessionId)) return; + if (blockedSendRouteSessions.has(event.sessionId)) { + throw new Error( + "Voice transcript is waiting for its bound chat to become available.", + ); + } const route = activeSendRoute; if (!route || route.sessionId !== event.sessionId) { @@ -489,6 +490,11 @@ function ensureVoiceEventDeliveryInitialized() { const opportunity = await waitForVoiceDeliveryOpportunity( event.sessionId, ); + if (blockedSendRouteSessions.has(event.sessionId)) { + throw new Error( + "Voice transcript is waiting for its bound chat to become available.", + ); + } const currentRoute = activeSendRoute; if (!currentRoute || currentRoute.sessionId !== event.sessionId) { throw new Error( @@ -669,14 +675,13 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - if (status.sessionId !== sessionId) { - blockedSendRouteSessions.delete(sessionId); - } else if (routeBlocked) { + if (routeBlocked) { blockedSendRouteSessions.add(sessionId); + } else { + blockedSendRouteSessions.delete(sessionId); } - const routeDiscardedForLifecycle = blockedSendRouteSessions.has(sessionId); const routeIsValid = - !routeDiscardedForLifecycle && + !routeBlocked && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -693,15 +698,13 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly || routeBlocked) && + (!enabled || !isGooseSession || readOnly) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; } - if (routeMount.drainPending || routeDiscardedForLifecycle) { - const routeSessionId = routeDiscardedForLifecycle - ? sessionId - : activeSendRoute?.sessionId; + if (routeMount.drainPending) { + const routeSessionId = activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( routeSessionId, @@ -726,7 +729,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - blockedSendRouteSessions.has(sessionId) || + routeBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -754,6 +757,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + routeBlocked, sessionId, status.lifecycle, status.sessionId, From 448f23c5f092900ca9bd50fd4f729dc7919518c5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 15:17:33 -0400 Subject: [PATCH 06/18] fix(voice): defer blocked transcripts without rejection --- .../hooks/useVoiceConversationController.ts | 5 ++-- .../stores/voiceConversationStore.test.ts | 27 +++++++++++++++++++ .../stores/voiceConversationStore.ts | 27 ++++++++++++++----- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 4c778f804..cf94cb821 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -12,6 +12,7 @@ import { steerPromptInSession } from "@/features/chat/lib/steerCore"; import { subscribeToVoiceConversationEvents, useVoiceConversationStore, + VoiceTranscriptDeferredError, } from "../stores/voiceConversationStore"; import { captureNativeAssistantSpeechHistory, @@ -448,7 +449,7 @@ function ensureVoiceEventDeliveryInitialized() { const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { if (blockedSendRouteSessions.has(event.sessionId)) { - throw new Error( + throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); } @@ -491,7 +492,7 @@ function ensureVoiceEventDeliveryInitialized() { event.sessionId, ); if (blockedSendRouteSessions.has(event.sessionId)) { - throw new Error( + throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); } diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 78631600e..f1bb947c8 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -198,6 +198,33 @@ describe("voice conversation store lifecycle ordering", () => { unsubscribe(); }); + it("defers a blocked transcript without spending its rejection budget", async () => { + const transcript = { + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "deferred-utterance", + text: "Wait for admission", + revision: 1, + deliveryAttempts: 2, + }; + mocks.drain.mockResolvedValueOnce([transcript]); + const module = await import("./voiceConversationStore"); + const unsubscribe = module.subscribeToVoiceConversationEvents(() => + Promise.reject(new module.VoiceTranscriptDeferredError("blocked")), + ); + await module.useVoiceConversationStore.getState().init(); + + await expect( + module.useVoiceConversationStore + .getState() + .drainPendingTranscripts("session-1"), + ).resolves.toBeUndefined(); + + expect(mocks.reject).not.toHaveBeenCalled(); + expect(mocks.acknowledge).not.toHaveBeenCalled(); + unsubscribe(); + }); + it("restores prior causal state after terminal transcript rejection", async () => { mocks.reject.mockResolvedValueOnce({ attempts: 3, terminal: true }); const module = await import("./voiceConversationStore"); diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 2fe6ad7a6..163b7ce8a 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -87,7 +87,11 @@ const voiceStartsInFlight = new Map>(); const eventSubscribers = new Set< (event: VoiceConversationEvent) => void | Promise >(); -const transcriptDeliveries = new Map>(); +type TranscriptDeliveryOutcome = "accepted" | "deferred" | "rejected"; +const transcriptDeliveries = new Map< + string, + Promise +>(); const deliveredTranscripts = new Set(); const deliveredTranscriptOrder: string[] = []; const MAX_DELIVERED_TRANSCRIPT_KEYS = 256; @@ -112,6 +116,8 @@ export function subscribeToVoiceConversationEvents( return () => eventSubscribers.delete(subscriber); } +export class VoiceTranscriptDeferredError extends Error {} + export async function blockVoiceConversationStarts( sessionId: string, ): Promise<() => Promise> { @@ -161,12 +167,12 @@ function rememberDeliveredTranscript(key: string) { async function deliverTranscriptOnce( transcript: PendingVoiceTranscript, -): Promise { +): Promise { const key = transcriptKey(transcript); if (deliveredTranscripts.has(key)) { await acknowledgeVoiceConversationTranscript(transcript); priorFinalizedTranscriptKeys.delete(key); - return true; + return "accepted"; } const existing = transcriptDeliveries.get(key); @@ -175,17 +181,22 @@ async function deliverTranscriptOnce( const event = { type: "user" as const, ...transcript }; const finalizedKey = finalizedTranscriptKey(transcript); const subscribers = [...eventSubscribers]; - if (subscribers.length === 0) return false; + if (subscribers.length === 0) return "rejected"; const delivery = (async () => { const results = await Promise.allSettled( subscribers.map((subscriber) => subscriber(event)), ); const accepted = results.some((result) => result.status === "fulfilled"); + const deferred = results.some( + (result) => + result.status === "rejected" && + result.reason instanceof VoiceTranscriptDeferredError, + ); if (accepted) { rememberDeliveredTranscript(key); await acknowledgeVoiceConversationTranscript(transcript); priorFinalizedTranscriptKeys.delete(key); - } else { + } else if (!deferred) { const rejection = await rejectVoiceConversationTranscript(transcript); if (rejection.terminal) { const priorKey = priorFinalizedTranscriptKeys.get(key) ?? null; @@ -205,7 +216,7 @@ async function deliverTranscriptOnce( ); } } - return accepted; + return accepted ? "accepted" : deferred ? "deferred" : "rejected"; })().finally(() => transcriptDeliveries.delete(key)); transcriptDeliveries.set(key, delivery); @@ -978,7 +989,9 @@ export const useVoiceConversationStore = create( if (!alreadyDelivered || current === null || current === key) { observeFinalizedTranscript(transcript); } - if (!(await deliverTranscriptOnce(transcript))) { + const outcome = await deliverTranscriptOnce(transcript); + if (outcome === "deferred") return; + if (outcome === "rejected") { throw new Error("Voice transcript delivery was rejected."); } } From 68943138ee7423a928dba07789f713ed51c1e869 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 06:27:26 -0400 Subject: [PATCH 07/18] fix(voice): preserve deferred transcript state --- src/features/chat/ui/ChatView.tsx | 22 +++-- .../ui/__tests__/ChatView.mcpApp.test.tsx | 5 ++ .../useVoiceConversationController.test.ts | 86 ++++++++++++++++++- .../hooks/useVoiceConversationController.ts | 34 +++++--- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index a7d643ea2..df9396615 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -244,6 +244,18 @@ export function ChatView({ voiceInput.backend, voiceOutput.backend, ); + const voiceAdmissionPermanentlyBlocked = + composerBinding.target.kind === "existingSession" && + Boolean(composerBinding.target.admission.blockingReason); + const voiceDeliveryTemporarilyBlocked = + !voiceAdmissionPermanentlyBlocked && + ((composerBinding.target.kind === "existingSession" && + composerBinding.target.admission.securityConfirmationPending) || + controller.projectMetadataPending || + controller.isCompactingContext || + controller.isLoadingHistory || + !controller.workspaceContextReady || + controller.queue.queuedMessage !== null); const voiceConversation = useVoiceConversationController({ sessionId, // Voice delivery only needs to wait for admission. Holding its per-session @@ -264,14 +276,8 @@ export function ChatView({ }); }, readOnly: Boolean(readOnlyStatus), - routeBlocked: admissionBlocked, - disabled: - admissionBlocked || - controller.projectMetadataPending || - controller.isCompactingContext || - controller.isLoadingHistory || - !controller.workspaceContextReady || - controller.queue.queuedMessage !== null, + routeBlocked: voiceDeliveryTemporarilyBlocked, + disabled: admissionBlocked || voiceDeliveryTemporarilyBlocked, }); const isAgentBuilderOpen = agentBuilderOpenForLayout; const patchSession = useChatSessionStore((s) => s.patchSession); diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 5b34991a5..6e56929fb 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -454,6 +454,7 @@ describe("ChatView MCP app messaging", () => { projectMetadataPending: false, isCompactingContext: false, workspaceSetupInProgress: false, + workspaceContextReady: true, queue: { queuedMessage: null, dismiss: vi.fn() }, draftValue: "", handleDraftChange: mocks.handleDraftChange, @@ -687,8 +688,10 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { onSend: (text: string) => boolean; disabled: boolean; + routeBlocked: boolean; }; expect(voiceOptions.disabled).toBe(true); + expect(voiceOptions.routeBlocked).toBe(true); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); @@ -1389,8 +1392,10 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { onSend: (text: string) => boolean; disabled: boolean; + routeBlocked: boolean; }; expect(voiceOptions.disabled).toBe(true); + expect(voiceOptions.routeBlocked).toBe(false); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index fa084a9e4..12d96a5f1 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -227,7 +227,8 @@ describe("voice transcript delivery coordination", () => { nativeAssistantSpeechMocks.capture.mockClear(); nativeAssistantSpeechMocks.start.mockClear(); nativeAssistantSpeechMocks.stop.mockClear(); - nativeAssistantSpeechMocks.takeNotices.mockClear(); + nativeAssistantSpeechMocks.takeNotices.mockReset(); + nativeAssistantSpeechMocks.takeNotices.mockReturnValue(null); voiceApiMocks.confirmForegroundSession.mockReset(); voiceApiMocks.confirmForegroundSession.mockResolvedValue(1); microphonePermissionMocks.getStatus.mockReset(); @@ -304,7 +305,7 @@ describe("voice transcript delivery coordination", () => { init: vi.fn().mockResolvedValue(undefined), }); const { rerender } = renderHook( - ({ readOnly }) => + ({ readOnly, routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", onSend, @@ -313,12 +314,13 @@ describe("voice transcript delivery coordination", () => { pocketReady: true, onPocketSetupRequired: vi.fn(), readOnly, + routeBlocked, }), - { initialProps: { readOnly: false } }, + { initialProps: { readOnly: false, routeBlocked: false } }, ); await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); - rerender({ readOnly: true }); + rerender({ readOnly: true, routeBlocked: true }); await expect( voiceStoreMocks.subscriber?.({ type: "user", @@ -333,6 +335,82 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("defers mid-flight without error UI or consuming playback context", async () => { + const onSend = vi.fn().mockResolvedValue(true); + nativeAssistantSpeechMocks.takeNotices.mockReturnValue("playback context"); + useChatStore.getState().setChatState("session-1", "waiting"); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + const transcript = { + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-mid-flight", + text: "deliver after the block", + revision: 1, + deliveryAttempts: 0, + }; + const delivery = voiceStoreMocks.subscriber?.(transcript); + await waitFor(() => + expect(useVoiceConversationStore.getState().uiState).toBe( + "user-speaking", + ), + ); + + rerender({ routeBlocked: true }); + act(() => useChatStore.getState().setChatState("session-1", "idle")); + + await expect(delivery).rejects.toThrow("waiting for its bound chat"); + expect(useVoiceConversationStore.getState().uiState).toBe("listening"); + expect(nativeAssistantSpeechMocks.takeNotices).not.toHaveBeenCalled(); + expect(onSend).not.toHaveBeenCalled(); + expect( + useChatStore.getState().messagesBySession["session-1"] ?? [], + ).toEqual([]); + + rerender({ routeBlocked: false }); + await expect( + voiceStoreMocks.subscriber?.(transcript), + ).resolves.toBeUndefined(); + expect(onSend).toHaveBeenCalledWith( + "deliver after the block", + undefined, + undefined, + expect.objectContaining({ + assistantPrompt: "playback context", + displayText: "deliver after the block", + }), + ); + }); + it("defers transcripts until admission is unblocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index cf94cb821..16e85a5e2 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -479,12 +479,6 @@ function ensureVoiceEventDeliveryInitialized() { voiceConversationRevision: event.revision, }, }; - const playbackNotice = takeVoicePlaybackNotices(event.sessionId); - const displayOptions = { - ...sendOptions, - ...(playbackNotice ? { assistantPrompt: playbackNotice } : {}), - displayText: event.text, - }; try { // This runs inside the per-session queue, so a prior send can change // the opportunity to steer before the next transcript is evaluated. @@ -502,6 +496,12 @@ function ensureVoiceEventDeliveryInitialized() { "Voice transcript could not be sent because its bound chat is unavailable.", ); } + const playbackNotice = takeVoicePlaybackNotices(event.sessionId); + const displayOptions = { + ...sendOptions, + ...(playbackNotice ? { assistantPrompt: playbackNotice } : {}), + displayText: event.text, + }; store.setUiState("agent-working"); const delivered = opportunity === "steer" @@ -542,6 +542,16 @@ function ensureVoiceEventDeliveryInitialized() { resetVoiceUiWhenRunSettles(event.sessionId, deliveryRevision); } catch (deliveryError) { const current = useVoiceConversationStore.getState(); + if (deliveryError instanceof VoiceTranscriptDeferredError) { + if ( + current.status.lifecycle === "running" && + current.status.sessionId === event.sessionId && + current.status.revision >= deliveryRevision + ) { + current.setUiState("listening"); + } + throw deliveryError; + } if ( current.status.lifecycle === "running" && current.status.sessionId === event.sessionId && @@ -654,6 +664,8 @@ export function useVoiceConversationController({ (state) => state.clearRequestedStart, ); const previousPocketReady = useRef(pocketReady); + const deliveryBlocked = + routeBlocked && enabled && isGooseSession && !readOnly; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -676,13 +688,13 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - if (routeBlocked) { + if (deliveryBlocked) { blockedSendRouteSessions.add(sessionId); } else { blockedSendRouteSessions.delete(sessionId); } const routeIsValid = - !routeBlocked && + !deliveryBlocked && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -721,7 +733,7 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, - routeBlocked, + deliveryBlocked, sessionId, status.sessionId, ]); @@ -730,7 +742,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - routeBlocked || + deliveryBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -758,7 +770,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, - routeBlocked, + deliveryBlocked, sessionId, status.lifecycle, status.sessionId, From d310920b47b66adbbe80a34f68d967185553326a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 07:01:00 -0400 Subject: [PATCH 08/18] fix(voice): clear permanently unavailable routes --- src/features/chat/ui/ChatView.tsx | 1 + .../ui/__tests__/ChatView.mcpApp.test.tsx | 4 ++ .../useVoiceConversationController.test.ts | 47 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 13 +++-- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index df9396615..f663df1eb 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -277,6 +277,7 @@ export function ChatView({ }, readOnly: Boolean(readOnlyStatus), routeBlocked: voiceDeliveryTemporarilyBlocked, + routeUnavailable: voiceAdmissionPermanentlyBlocked, disabled: admissionBlocked || voiceDeliveryTemporarilyBlocked, }); const isAgentBuilderOpen = agentBuilderOpenForLayout; diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 6e56929fb..6d8701366 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -689,9 +689,11 @@ describe("ChatView MCP app messaging", () => { onSend: (text: string) => boolean; disabled: boolean; routeBlocked: boolean; + routeUnavailable: boolean; }; expect(voiceOptions.disabled).toBe(true); expect(voiceOptions.routeBlocked).toBe(true); + expect(voiceOptions.routeUnavailable).toBe(false); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); @@ -1393,9 +1395,11 @@ describe("ChatView MCP app messaging", () => { onSend: (text: string) => boolean; disabled: boolean; routeBlocked: boolean; + routeUnavailable: boolean; }; expect(voiceOptions.disabled).toBe(true); expect(voiceOptions.routeBlocked).toBe(false); + expect(voiceOptions.routeUnavailable).toBe(true); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 12d96a5f1..4b58efadd 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -335,6 +335,53 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("releases a retained transcript route after permanent admission failure", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ disabled, routeUnavailable }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + disabled, + routeUnavailable, + }), + { initialProps: { disabled: false, routeUnavailable: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ disabled: true, routeUnavailable: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-admission-failed", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); + it("defers mid-flight without error UI or consuming playback context", async () => { const onSend = vi.fn().mockResolvedValue(true); nativeAssistantSpeechMocks.takeNotices.mockReturnValue("playback context"); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 16e85a5e2..271cca2d0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -617,6 +617,7 @@ export interface UseVoiceConversationControllerOptions { readOnly?: boolean; disabled?: boolean; routeBlocked?: boolean; + routeUnavailable?: boolean; } export function useVoiceConversationController({ @@ -631,6 +632,7 @@ export function useVoiceConversationController({ readOnly = false, disabled = false, routeBlocked = false, + routeUnavailable = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -665,7 +667,7 @@ export function useVoiceConversationController({ ); const previousPocketReady = useRef(pocketReady); const deliveryBlocked = - routeBlocked && enabled && isGooseSession && !readOnly; + routeBlocked && enabled && isGooseSession && !readOnly && !routeUnavailable; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -695,6 +697,7 @@ export function useVoiceConversationController({ } const routeIsValid = !deliveryBlocked && + !routeUnavailable && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -711,7 +714,7 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly) && + (!enabled || !isGooseSession || readOnly || routeUnavailable) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; @@ -734,6 +737,7 @@ export function useVoiceConversationController({ onSend, readOnly, deliveryBlocked, + routeUnavailable, sessionId, status.sessionId, ]); @@ -743,6 +747,7 @@ export function useVoiceConversationController({ status.lifecycle !== "running" || status.sessionId !== sessionId || deliveryBlocked || + routeUnavailable || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -771,6 +776,7 @@ export function useVoiceConversationController({ isGooseSession, readOnly, deliveryBlocked, + routeUnavailable, sessionId, status.lifecycle, status.sessionId, @@ -1032,7 +1038,8 @@ export function useVoiceConversationController({ ]); const isActive = status.sessionId !== null && status.lifecycle !== "stopped"; - const sessionEligible = enabled && isGooseSession && !readOnly && !disabled; + const sessionEligible = + enabled && isGooseSession && !readOnly && !disabled && !routeUnavailable; const canToggle = sessionEligible && (!pocketReady || status.available); const toggle = useCallback(async () => { From 2a7c40f59f57d49f2062d7e9dc0e651a480f07b1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:13:28 -0400 Subject: [PATCH 09/18] fix(voice): scope transcript blocking to route owners --- .../useVoiceConversationController.test.ts | 128 ++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 122 +++++++++++++---- 2 files changed, 221 insertions(+), 29 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 4b58efadd..f6f1d6bc0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -523,6 +523,134 @@ describe("voice transcript delivery coordination", () => { ); }); + it("uses blocking state from the mounted route that owns the call", async () => { + const ownerSend = vi.fn().mockResolvedValue(true); + const duplicateSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-multi-view", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + + renderHook(() => + useVoiceConversationController({ + sessionId: "session-multi-view", + onSend: ownerSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked: false, + }), + ); + renderHook(() => + useVoiceConversationController({ + sessionId: "session-multi-view", + onSend: duplicateSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked: true, + }), + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-multi-view", + lifecycleId: "lifecycle-multi-view", + id: "utterance-owner-ready", + text: "deliver through the owner", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + + expect(ownerSend).toHaveBeenCalledOnce(); + expect(duplicateSend).not.toHaveBeenCalled(); + }); + + it("keeps a blocked owner authoritative until it unmounts", async () => { + const ownerSend = vi.fn().mockResolvedValue(true); + const replacementSend = vi.fn().mockResolvedValue(true); + const drainPendingTranscripts = vi.fn().mockResolvedValue(undefined); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-owner-blocked", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + drainPendingTranscripts, + }); + + const owner = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-owner-blocked", + onSend: ownerSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + owner.rerender({ routeBlocked: true }); + renderHook(() => + useVoiceConversationController({ + sessionId: "session-owner-blocked", + onSend: replacementSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked: false, + }), + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + const transcript = { + type: "user", + sessionId: "session-owner-blocked", + lifecycleId: "lifecycle-owner-blocked", + id: "utterance-owner-blocked", + text: "wait for the owner", + revision: 1, + deliveryAttempts: 0, + }; + await expect(voiceStoreMocks.subscriber?.(transcript)).rejects.toThrow( + "waiting for its bound chat", + ); + expect(ownerSend).not.toHaveBeenCalled(); + expect(replacementSend).not.toHaveBeenCalled(); + + owner.unmount(); + await waitFor(() => expect(drainPendingTranscripts).toHaveBeenCalled()); + await expect( + voiceStoreMocks.subscriber?.(transcript), + ).resolves.toBeUndefined(); + expect(replacementSend).toHaveBeenCalledOnce(); + }); + it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 271cca2d0..f6e7dd3ad 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -30,14 +30,17 @@ import type { VoiceInputBackend } from "../lib/voiceInputPreference"; import type { SiriVoiceSelection } from "../api/siriVoice"; interface VoiceSendRoute { + owner: symbol; sessionId: string; send: ChatInputSendHandler; + blocked: boolean; + canClaim: boolean; } // The backend conversation is process-wide, but voice input remains bound to // the chat that started the active lifecycle until that lifecycle terminates. let activeSendRoute: VoiceSendRoute | null = null; -const blockedSendRouteSessions = new Set(); +const mountedSendRoutes = new Map(); let deliveryInitialized = false; const operationInFlightBySession = new Set(); let replacementOperationInFlight = false; @@ -60,6 +63,22 @@ export function createVoiceTranscriptDeliveryQueue() { const enqueueVoiceTranscriptDelivery = createVoiceTranscriptDeliveryQueue(); +function activeRouteIsBlocked(sessionId: string): boolean { + return activeSendRoute?.sessionId === sessionId && activeSendRoute.blocked; +} + +function releaseVoiceSendRoute(owner: symbol): VoiceSendRoute | null { + const released = mountedSendRoutes.get(owner); + mountedSendRoutes.delete(owner); + if (activeSendRoute?.owner !== owner) return activeSendRoute; + + activeSendRoute = + [...mountedSendRoutes.values()].find( + (route) => route.sessionId === released?.sessionId && route.canClaim, + ) ?? null; + return activeSendRoute; +} + export function canBindVoiceSendRoute(options: { enabled: boolean; isGooseSession: boolean; @@ -448,7 +467,7 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { - if (blockedSendRouteSessions.has(event.sessionId)) { + if (activeRouteIsBlocked(event.sessionId)) { throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); @@ -485,7 +504,7 @@ function ensureVoiceEventDeliveryInitialized() { const opportunity = await waitForVoiceDeliveryOpportunity( event.sessionId, ); - if (blockedSendRouteSessions.has(event.sessionId)) { + if (activeRouteIsBlocked(event.sessionId)) { throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); @@ -666,6 +685,19 @@ export function useVoiceConversationController({ (state) => state.clearRequestedStart, ); const previousPocketReady = useRef(pocketReady); + const routeOwnerRef = useRef<{ + sessionId: string; + owner: symbol; + } | null>(null); + if (routeOwnerRef.current?.sessionId !== sessionId) { + routeOwnerRef.current = { + sessionId, + owner: Symbol("voice-send-route"), + }; + } + const routeOwner = routeOwnerRef.current.owner; + const drainPendingTranscriptsRef = useRef(drainPendingTranscripts); + drainPendingTranscriptsRef.current = drainPendingTranscripts; const deliveryBlocked = routeBlocked && enabled && isGooseSession && !readOnly && !routeUnavailable; @@ -690,36 +722,45 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - if (deliveryBlocked) { - blockedSendRouteSessions.add(sessionId); - } else { - blockedSendRouteSessions.delete(sessionId); - } - const routeIsValid = - !deliveryBlocked && - !routeUnavailable && - canBindVoiceSendRoute({ - enabled, - isGooseSession, - readOnly, - disabled, - }); + const routeCanPersist = + enabled && isGooseSession && !readOnly && !routeUnavailable; + const routeCanClaim = + routeCanPersist && + canBindVoiceSendRoute({ enabled, isGooseSession, readOnly, disabled }); + const registeredRoute: VoiceSendRoute = { + owner: routeOwner, + sessionId, + send: onSend, + blocked: deliveryBlocked, + canClaim: routeCanClaim, + }; + mountedSendRoutes.set(routeOwner, registeredRoute); const activeVoiceSessionId = status.sessionId; const routeMount = resolveVoiceRouteMount({ - routeIsValid, + routeIsValid: routeCanClaim, activeVoiceSessionId, boundRouteSessionId: activeSendRoute?.sessionId ?? null, candidateSessionId: sessionId, }); - if (routeMount.claimRoute) { - activeSendRoute = { sessionId, send: onSend }; + if (activeSendRoute?.owner === routeOwner) { + if (routeCanPersist) { + activeSendRoute = registeredRoute; + } else { + releaseVoiceSendRoute(routeOwner); + mountedSendRoutes.set(routeOwner, registeredRoute); + } } else if ( - (!enabled || !isGooseSession || readOnly || routeUnavailable) && - activeSendRoute?.sessionId === sessionId + routeMount.claimRoute && + (activeSendRoute === null || + activeSendRoute.sessionId !== activeVoiceSessionId) ) { - activeSendRoute = null; + activeSendRoute = registeredRoute; } - if (routeMount.drainPending) { + if ( + routeMount.drainPending && + activeSendRoute?.owner === routeOwner && + !activeSendRoute.blocked + ) { const routeSessionId = activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( @@ -737,11 +778,25 @@ export function useVoiceConversationController({ onSend, readOnly, deliveryBlocked, + routeOwner, routeUnavailable, sessionId, status.sessionId, ]); + useEffect( + () => () => { + const replacement = releaseVoiceSendRoute(routeOwner); + if (!replacement || replacement.blocked) return; + void drainPendingTranscriptsRef + .current(replacement.sessionId, transcriptAlreadyInChat) + .catch((drainError) => { + addErrorNotification(replacement.sessionId, errorText(drainError)); + }); + }, + [routeOwner], + ); + useEffect(() => { if ( status.lifecycle !== "running" || @@ -832,7 +887,14 @@ export function useVoiceConversationController({ ensureVoiceEventDeliveryInitialized(); const assistantSpeechHistory = captureNativeAssistantSpeechHistory(sessionId); - const route = { sessionId, send: onSend }; + const route: VoiceSendRoute = { + owner: routeOwner, + sessionId, + send: onSend, + blocked: deliveryBlocked, + canClaim: true, + }; + mountedSendRoutes.set(routeOwner, route); activeSendRoute = route; try { const foregroundGeneration = @@ -847,8 +909,8 @@ export function useVoiceConversationController({ backendStatus.sessionId === sessionId && backendStatus.ownerWindowLabel === currentWindowLabel; if (isVoiceMicrophoneCaptureError(startError)) { - if (activeSendRoute?.sessionId === route.sessionId) { - activeSendRoute = null; + if (activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); let cleanupError: unknown = null; @@ -916,16 +978,18 @@ export function useVoiceConversationController({ } } if (!conversationStarted) { - if (activeSendRoute?.sessionId === route.sessionId) { - activeSendRoute = null; + if (activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); } } }, [ inputBackend, + deliveryBlocked, onPocketSetupRequired, onSend, + routeOwner, sessionId, start, startAssistantSpeech, From a6fc47deea5663549cb648b40b032ba302f1f42b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:33:10 -0400 Subject: [PATCH 10/18] fix(voice): preserve routes across view navigation --- .../useVoiceConversationController.test.ts | 45 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 14 ++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index f6f1d6bc0..4dcdd81d7 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -651,6 +651,51 @@ describe("voice transcript delivery coordination", () => { expect(replacementSend).toHaveBeenCalledOnce(); }); + it("preserves the active route while its view is unmounted", async () => { + const ownerSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-unmounted-owner", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + + const owner = renderHook(() => + useVoiceConversationController({ + sessionId: "session-unmounted-owner", + onSend: ownerSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + owner.unmount(); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-unmounted-owner", + lifecycleId: "lifecycle-unmounted-owner", + id: "utterance-after-navigation", + text: "keep listening after navigation", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + + expect(ownerSend).toHaveBeenCalledOnce(); + }); + it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index f6e7dd3ad..de68886c3 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -67,15 +67,20 @@ function activeRouteIsBlocked(sessionId: string): boolean { return activeSendRoute?.sessionId === sessionId && activeSendRoute.blocked; } -function releaseVoiceSendRoute(owner: symbol): VoiceSendRoute | null { +function releaseVoiceSendRoute( + owner: symbol, + preserveActiveRoute = false, +): VoiceSendRoute | null { const released = mountedSendRoutes.get(owner); mountedSendRoutes.delete(owner); if (activeSendRoute?.owner !== owner) return activeSendRoute; - activeSendRoute = + const replacement = [...mountedSendRoutes.values()].find( (route) => route.sessionId === released?.sessionId && route.canClaim, ) ?? null; + activeSendRoute = + replacement ?? (preserveActiveRoute ? (released ?? null) : null); return activeSendRoute; } @@ -752,7 +757,8 @@ export function useVoiceConversationController({ } else if ( routeMount.claimRoute && (activeSendRoute === null || - activeSendRoute.sessionId !== activeVoiceSessionId) + activeSendRoute.sessionId !== activeVoiceSessionId || + !mountedSendRoutes.has(activeSendRoute.owner)) ) { activeSendRoute = registeredRoute; } @@ -786,7 +792,7 @@ export function useVoiceConversationController({ useEffect( () => () => { - const replacement = releaseVoiceSendRoute(routeOwner); + const replacement = releaseVoiceSendRoute(routeOwner, true); if (!replacement || replacement.blocked) return; void drainPendingTranscriptsRef .current(replacement.sessionId, transcriptAlreadyInChat) From b5dac266cac527576869d892fed60146281fe1d5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:34:49 -0400 Subject: [PATCH 11/18] fix(voice): abort stale conversation starts --- .../useVoiceConversationController.test.ts | 51 ++++++++++++++++ .../hooks/useVoiceConversationController.ts | 58 +++++++++++++++---- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 4dcdd81d7..3962ac0f9 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -1756,6 +1756,57 @@ describe("voice transcript delivery coordination", () => { expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); + it("does not start after admission becomes permanently unavailable", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const refreshRequest = deferred(); + const start = vi.fn().mockResolvedValue({ + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockReturnValue(refreshRequest.promise), + start, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const control = renderHook( + ({ routeUnavailable }) => + useVoiceConversationController({ ...options, routeUnavailable }), + { initialProps: { routeUnavailable: false } }, + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); + }); + control.rerender({ routeUnavailable: true }); + refreshRequest.resolve(stopped); + await toggling; + + expect(start).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent controls for the same session", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index de68886c3..e9f6bbcb4 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -701,6 +701,29 @@ export function useVoiceConversationController({ }; } const routeOwner = routeOwnerRef.current.owner; + const startEligibilityRef = useRef<{ + routeOwner: symbol; + sessionId: string; + inputBackend: VoiceInputBackend | null; + eligible: boolean; + }>({ + routeOwner, + sessionId, + inputBackend, + eligible: false, + }); + startEligibilityRef.current = { + routeOwner, + sessionId, + inputBackend, + eligible: + inputBackend !== null && + enabled && + isGooseSession && + !readOnly && + !disabled && + !routeUnavailable, + }; const drainPendingTranscriptsRef = useRef(drainPendingTranscripts); drainPendingTranscriptsRef.current = drainPendingTranscripts; const deliveryBlocked = @@ -877,6 +900,16 @@ export function useVoiceConversationController({ const startCurrentConversation = useCallback(async () => { if (inputBackend === null) return; + const startIsStillEligible = () => { + const current = startEligibilityRef.current; + return ( + current.eligible && + current.routeOwner === routeOwner && + current.sessionId === sessionId && + current.inputBackend === inputBackend + ); + }; + if (!startIsStillEligible()) return; try { if ((await getMicrophonePermissionStatus()) === "denied") { onPocketSetupRequired(); @@ -887,24 +920,27 @@ export function useVoiceConversationController({ // of truth and provides the recovery path for unsupported platforms, // stale permission state, and other audio startup failures. } + if (!startIsStillEligible()) return; // Do not rely on the mount effect racing ahead of the user's first // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. ensureVoiceEventDeliveryInitialized(); const assistantSpeechHistory = captureNativeAssistantSpeechHistory(sessionId); - const route: VoiceSendRoute = { - owner: routeOwner, - sessionId, - send: onSend, - blocked: deliveryBlocked, - canClaim: true, - }; - mountedSendRoutes.set(routeOwner, route); - activeSendRoute = route; + let route: VoiceSendRoute | null = null; try { const foregroundGeneration = await confirmVoiceConversationForegroundSession(sessionId); + if (!startIsStillEligible()) return; + route = { + owner: routeOwner, + sessionId, + send: onSend, + blocked: deliveryBlocked, + canClaim: true, + }; + mountedSendRoutes.set(routeOwner, route); + activeSendRoute = route; await start(sessionId, inputBackend, foregroundGeneration); startAssistantSpeech(assistantSpeechHistory); } catch (startError) { @@ -915,7 +951,7 @@ export function useVoiceConversationController({ backendStatus.sessionId === sessionId && backendStatus.ownerWindowLabel === currentWindowLabel; if (isVoiceMicrophoneCaptureError(startError)) { - if (activeSendRoute?.owner === route.owner) { + if (route && activeSendRoute?.owner === route.owner) { releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); @@ -984,7 +1020,7 @@ export function useVoiceConversationController({ } } if (!conversationStarted) { - if (activeSendRoute?.owner === route.owner) { + if (route && activeSendRoute?.owner === route.owner) { releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); From 7dfd48007c7ff29ea380a39caca2418aed528ad6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:48:52 -0400 Subject: [PATCH 12/18] fix(voice): release stale route blocks on navigation --- .../useVoiceConversationController.test.ts | 22 +++++++++++-------- .../hooks/useVoiceConversationController.ts | 7 ++++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 3962ac0f9..fe639c6d9 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -668,18 +668,22 @@ describe("voice transcript delivery coordination", () => { init: vi.fn().mockResolvedValue(undefined), }); - const owner = renderHook(() => - useVoiceConversationController({ - sessionId: "session-unmounted-owner", - onSend: ownerSend, - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: vi.fn(), - }), + const owner = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-unmounted-owner", + onSend: ownerSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, ); await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + owner.rerender({ routeBlocked: true }); owner.unmount(); await expect( voiceStoreMocks.subscriber?.({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index e9f6bbcb4..80da63189 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -79,8 +79,11 @@ function releaseVoiceSendRoute( [...mountedSendRoutes.values()].find( (route) => route.sessionId === released?.sessionId && route.canClaim, ) ?? null; - activeSendRoute = - replacement ?? (preserveActiveRoute ? (released ?? null) : null); + const preservedRoute = + preserveActiveRoute && released + ? { ...released, blocked: false, canClaim: false } + : null; + activeSendRoute = replacement ?? preservedRoute; return activeSendRoute; } From cf078e9ac2a4796eb218a22d3b39ddbefa0617d6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:49:22 -0400 Subject: [PATCH 13/18] fix(voice): stop starts after eligibility revocation --- .../useVoiceConversationController.test.ts | 62 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 23 ++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index fe639c6d9..08fa2c937 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -1811,6 +1811,68 @@ describe("voice transcript delivery coordination", () => { expect(start).not.toHaveBeenCalled(); }); + it("stops a native start when admission becomes unavailable in flight", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const starting = { + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockImplementation(async () => { + const status = await startRequest.promise; + useVoiceConversationStore.setState({ status, uiState: "starting" }); + return status; + }); + const stop = vi.fn().mockResolvedValue(stopped); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + start, + stop, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const control = renderHook( + ({ routeUnavailable }) => + useVoiceConversationController({ ...options, routeUnavailable }), + { initialProps: { routeUnavailable: false } }, + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await act(async () => { + control.rerender({ routeUnavailable: true }); + startRequest.resolve(starting); + await toggling; + }); + + expect(stop).toHaveBeenCalledOnce(); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent controls for the same session", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 80da63189..320204f0d 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -944,7 +944,28 @@ export function useVoiceConversationController({ }; mountedSendRoutes.set(routeOwner, route); activeSendRoute = route; - await start(sessionId, inputBackend, foregroundGeneration); + const startedStatus = await start( + sessionId, + inputBackend, + foregroundGeneration, + ); + if (!startIsStillEligible()) { + if (activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); + } + if ( + startedStatus.sessionId === sessionId && + startedStatus.lifecycle !== "stopped" && + startedStatus.lifecycle !== "unavailable" + ) { + try { + await stop(); + } catch (stopError) { + addErrorNotification(sessionId, errorText(stopError)); + } + } + return; + } startAssistantSpeech(assistantSpeechHistory); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; From ba19ff117a7f736f8584e3993ccb5a92893c6ecf Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 13:59:49 -0400 Subject: [PATCH 14/18] fix(voice): preserve admission blocks across navigation --- .../useVoiceConversationController.test.ts | 84 ++++++++++++++++--- .../hooks/useVoiceConversationController.ts | 7 +- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 08fa2c937..eb3ef04bd 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -668,10 +668,55 @@ describe("voice transcript delivery coordination", () => { init: vi.fn().mockResolvedValue(undefined), }); + const owner = renderHook(() => + useVoiceConversationController({ + sessionId: "session-unmounted-owner", + onSend: ownerSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + owner.unmount(); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-unmounted-owner", + lifecycleId: "lifecycle-unmounted-owner", + id: "utterance-after-navigation", + text: "keep listening after navigation", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + + expect(ownerSend).toHaveBeenCalledOnce(); + }); + + it("keeps an unmounted blocked route deferred until a replacement mounts", async () => { + const ownerSend = vi.fn().mockResolvedValue(true); + const replacementSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-unmounted-blocked", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); const owner = renderHook( ({ routeBlocked }) => useVoiceConversationController({ - sessionId: "session-unmounted-owner", + sessionId: "session-unmounted-blocked", onSend: ownerSend, enabled: true, isGooseSession: true, @@ -685,19 +730,34 @@ describe("voice transcript delivery coordination", () => { await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); owner.rerender({ routeBlocked: true }); owner.unmount(); - await expect( - voiceStoreMocks.subscriber?.({ - type: "user", - sessionId: "session-unmounted-owner", - lifecycleId: "lifecycle-unmounted-owner", - id: "utterance-after-navigation", - text: "keep listening after navigation", - revision: 1, - deliveryAttempts: 0, + const transcript = { + type: "user", + sessionId: "session-unmounted-blocked", + lifecycleId: "lifecycle-unmounted-blocked", + id: "utterance-unmounted-blocked", + text: "wait for a safe route", + revision: 1, + deliveryAttempts: 0, + }; + await expect(voiceStoreMocks.subscriber?.(transcript)).rejects.toThrow( + "waiting for its bound chat", + ); + expect(ownerSend).not.toHaveBeenCalled(); + + renderHook(() => + useVoiceConversationController({ + sessionId: "session-unmounted-blocked", + onSend: replacementSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), }), + ); + await expect( + voiceStoreMocks.subscriber?.(transcript), ).resolves.toBeUndefined(); - - expect(ownerSend).toHaveBeenCalledOnce(); + expect(replacementSend).toHaveBeenCalledOnce(); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 320204f0d..62418cfc0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -79,11 +79,8 @@ function releaseVoiceSendRoute( [...mountedSendRoutes.values()].find( (route) => route.sessionId === released?.sessionId && route.canClaim, ) ?? null; - const preservedRoute = - preserveActiveRoute && released - ? { ...released, blocked: false, canClaim: false } - : null; - activeSendRoute = replacement ?? preservedRoute; + activeSendRoute = + replacement ?? (preserveActiveRoute ? (released ?? null) : null); return activeSendRoute; } From 13db6b3e0ab37278ba97f8e73bf5d3221371b81c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 14:00:26 -0400 Subject: [PATCH 15/18] fix(voice): scope stale-start lifecycle cleanup --- .../useVoiceConversationController.test.ts | 138 ++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 18 ++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index eb3ef04bd..f73daebd8 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -1933,6 +1933,144 @@ describe("voice transcript delivery coordination", () => { expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); }); + it("keeps a native start alive through a temporary delivery block", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const starting = { + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockImplementation(async () => { + const status = await startRequest.promise; + useVoiceConversationStore.setState({ status, uiState: "starting" }); + return status; + }); + const stop = vi.fn().mockResolvedValue(stopped); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + start, + stop, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const control = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + ...options, + routeBlocked, + disabled: routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await act(async () => { + control.rerender({ routeBlocked: true }); + startRequest.resolve(starting); + await toggling; + }); + + expect(stop).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledOnce(); + }); + + it("does not stop a replacement lifecycle after a stale start returns", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const staleStarting = { + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const replacement = { + ...staleStarting, + lifecycle: "running" as const, + sessionId: "session-b", + ownerWindowLabel: "session-window", + revision: 3, + }; + const startRequest = deferred(); + const start = vi.fn().mockImplementation(async () => { + const status = await startRequest.promise; + useVoiceConversationStore.setState({ + status: replacement, + uiState: "listening", + }); + return status; + }); + const stop = vi.fn().mockResolvedValue(stopped); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + start, + stop, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const control = renderHook( + ({ routeUnavailable }) => + useVoiceConversationController({ ...options, routeUnavailable }), + { initialProps: { routeUnavailable: false } }, + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await act(async () => { + control.rerender({ routeUnavailable: true }); + startRequest.resolve(staleStarting); + await toggling; + }); + + expect(stop).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent controls for the same session", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 62418cfc0..88daf3504 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -721,7 +721,6 @@ export function useVoiceConversationController({ enabled && isGooseSession && !readOnly && - !disabled && !routeUnavailable, }; const drainPendingTranscriptsRef = useRef(drainPendingTranscripts); @@ -955,10 +954,19 @@ export function useVoiceConversationController({ startedStatus.lifecycle !== "stopped" && startedStatus.lifecycle !== "unavailable" ) { - try { - await stop(); - } catch (stopError) { - addErrorNotification(sessionId, errorText(stopError)); + const currentStatus = useVoiceConversationStore.getState().status; + const staleLifecycleIsStillCurrent = + currentStatus.sessionId === startedStatus.sessionId && + currentStatus.ownerWindowLabel === startedStatus.ownerWindowLabel && + currentStatus.revision === startedStatus.revision && + currentStatus.lifecycle !== "stopped" && + currentStatus.lifecycle !== "unavailable"; + if (staleLifecycleIsStillCurrent) { + try { + await stop(); + } catch (stopError) { + addErrorNotification(sessionId, errorText(stopError)); + } } } return; From 589254efb3d90c211c8202d5ae2ba84acc7fa868 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 14:10:19 -0400 Subject: [PATCH 16/18] fix(voice): honor live admission blocks before start --- .../useVoiceConversationController.test.ts | 66 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 34 ++++++---- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index f73daebd8..1dd457143 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -1871,6 +1871,72 @@ describe("voice transcript delivery coordination", () => { expect(start).not.toHaveBeenCalled(); }); + it("does not start after admission becomes temporarily blocked", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const foregroundRequest = deferred(); + const start = vi.fn().mockResolvedValue({ + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }); + voiceApiMocks.confirmForegroundSession.mockReturnValue( + foregroundRequest.promise, + ); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + start, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const control = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + ...options, + routeBlocked, + disabled: routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); + }); + await vi.waitFor(() => + expect(voiceApiMocks.confirmForegroundSession).toHaveBeenCalledOnce(), + ); + act(() => { + control.rerender({ routeBlocked: true }); + }); + await act(async () => { + foregroundRequest.resolve(1); + await toggling; + }); + + expect(start).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + }); + it("stops a native start when admission becomes unavailable in flight", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 88daf3504..90b887881 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -701,16 +701,20 @@ export function useVoiceConversationController({ }; } const routeOwner = routeOwnerRef.current.owner; + const deliveryBlocked = + routeBlocked && enabled && isGooseSession && !readOnly && !routeUnavailable; const startEligibilityRef = useRef<{ routeOwner: symbol; sessionId: string; inputBackend: VoiceInputBackend | null; eligible: boolean; + deliveryBlocked: boolean; }>({ routeOwner, sessionId, inputBackend, eligible: false, + deliveryBlocked: false, }); startEligibilityRef.current = { routeOwner, @@ -722,11 +726,10 @@ export function useVoiceConversationController({ isGooseSession && !readOnly && !routeUnavailable, + deliveryBlocked, }; const drainPendingTranscriptsRef = useRef(drainPendingTranscripts); drainPendingTranscriptsRef.current = drainPendingTranscripts; - const deliveryBlocked = - routeBlocked && enabled && isGooseSession && !readOnly && !routeUnavailable; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -899,16 +902,22 @@ export function useVoiceConversationController({ const startCurrentConversation = useCallback(async () => { if (inputBackend === null) return; - const startIsStillEligible = () => { + const readCurrentStartEligibility = () => { const current = startEligibilityRef.current; - return ( + const stillEligible = current.eligible && current.routeOwner === routeOwner && current.sessionId === sessionId && - current.inputBackend === inputBackend - ); + current.inputBackend === inputBackend; + return { current, stillEligible }; }; - if (!startIsStillEligible()) return; + const startCanStillBegin = () => { + const { current, stillEligible } = readCurrentStartEligibility(); + return stillEligible && !current.deliveryBlocked; + }; + const startedCaptureMayContinue = () => + readCurrentStartEligibility().stillEligible; + if (!startCanStillBegin()) return; try { if ((await getMicrophonePermissionStatus()) === "denied") { onPocketSetupRequired(); @@ -919,7 +928,7 @@ export function useVoiceConversationController({ // of truth and provides the recovery path for unsupported platforms, // stale permission state, and other audio startup failures. } - if (!startIsStillEligible()) return; + if (!startCanStillBegin()) return; // Do not rely on the mount effect racing ahead of the user's first // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. @@ -930,12 +939,14 @@ export function useVoiceConversationController({ try { const foregroundGeneration = await confirmVoiceConversationForegroundSession(sessionId); - if (!startIsStillEligible()) return; + if (!startCanStillBegin()) return; + const { current: currentStartEligibility } = + readCurrentStartEligibility(); route = { owner: routeOwner, sessionId, send: onSend, - blocked: deliveryBlocked, + blocked: currentStartEligibility.deliveryBlocked, canClaim: true, }; mountedSendRoutes.set(routeOwner, route); @@ -945,7 +956,7 @@ export function useVoiceConversationController({ inputBackend, foregroundGeneration, ); - if (!startIsStillEligible()) { + if (!startedCaptureMayContinue()) { if (activeSendRoute?.owner === route.owner) { releaseVoiceSendRoute(route.owner); } @@ -1057,7 +1068,6 @@ export function useVoiceConversationController({ } }, [ inputBackend, - deliveryBlocked, onPocketSetupRequired, onSend, routeOwner, From 7050b5d38c3bef4c778ff08d65e8651f44b25be7 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 14:17:43 -0400 Subject: [PATCH 17/18] fix(voice): report blocked conversation replacements --- .../useVoiceConversationController.test.ts | 21 +++++++++++++++---- .../hooks/useVoiceConversationController.ts | 21 ++++++++++--------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 1dd457143..1d63bec31 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -2245,7 +2245,7 @@ describe("voice transcript delivery coordination", () => { finishStop = resolve; }), ); - const start = vi.fn().mockResolvedValue(undefined); + const start = vi.fn().mockResolvedValue(true); const replacement = replaceActiveVoiceConversation({ stop, start }); await Promise.resolve(); @@ -2267,6 +2267,7 @@ describe("voice transcript delivery coordination", () => { }); const start = vi.fn(async () => { order.push("start"); + return true; }); await expect( @@ -2276,7 +2277,7 @@ describe("voice transcript delivery coordination", () => { }); it("does not start when the target changes after stopping", async () => { - const start = vi.fn().mockResolvedValue(undefined); + const start = vi.fn().mockResolvedValue(true); await expect( replaceActiveVoiceConversation({ @@ -2296,7 +2297,7 @@ describe("voice transcript delivery coordination", () => { }); it("does not start a replacement when the active call remains running", async () => { - const start = vi.fn().mockResolvedValue(undefined); + const start = vi.fn().mockResolvedValue(true); await expect( replaceActiveVoiceConversation({ @@ -2311,7 +2312,7 @@ describe("voice transcript delivery coordination", () => { }); it("does not start a replacement when stopping the active call fails", async () => { - const start = vi.fn().mockResolvedValue(undefined); + const start = vi.fn().mockResolvedValue(true); await expect( replaceActiveVoiceConversation({ @@ -2322,6 +2323,18 @@ describe("voice transcript delivery coordination", () => { expect(start).not.toHaveBeenCalled(); }); + it("reports when replacement admission blocks the new start", async () => { + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockResolvedValue({ + lifecycle: "stopped", + sessionId: null, + }), + start: vi.fn().mockResolvedValue(false), + }), + ).resolves.toBe(false); + }); + it("drains retained transcripts without stealing a stopped session route", () => { expect( resolveVoiceRouteMount({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 90b887881..b61ff5d0a 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -127,7 +127,7 @@ export function shouldShowVoiceConversationControl(options: { export async function replaceActiveVoiceConversation(options: { stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; confirmTarget?: () => Promise; - start: () => Promise; + start: () => Promise; }): Promise { const stopped = await options.stop(); if ( @@ -137,8 +137,7 @@ export async function replaceActiveVoiceConversation(options: { return false; } await options.confirmTarget?.(); - await options.start(); - return true; + return options.start(); } export function shouldSuppressVoiceConversationControls(options: { @@ -901,7 +900,7 @@ export function useVoiceConversationController({ ); const startCurrentConversation = useCallback(async () => { - if (inputBackend === null) return; + if (inputBackend === null) return false; const readCurrentStartEligibility = () => { const current = startEligibilityRef.current; const stillEligible = @@ -917,18 +916,18 @@ export function useVoiceConversationController({ }; const startedCaptureMayContinue = () => readCurrentStartEligibility().stillEligible; - if (!startCanStillBegin()) return; + if (!startCanStillBegin()) return false; try { if ((await getMicrophonePermissionStatus()) === "denied") { onPocketSetupRequired(); - return; + return false; } } catch { // Permission inspection is an optimization. Capture remains the source // of truth and provides the recovery path for unsupported platforms, // stale permission state, and other audio startup failures. } - if (!startCanStillBegin()) return; + if (!startCanStillBegin()) return false; // Do not rely on the mount effect racing ahead of the user's first // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. @@ -939,7 +938,7 @@ export function useVoiceConversationController({ try { const foregroundGeneration = await confirmVoiceConversationForegroundSession(sessionId); - if (!startCanStillBegin()) return; + if (!startCanStillBegin()) return false; const { current: currentStartEligibility } = readCurrentStartEligibility(); route = { @@ -980,9 +979,10 @@ export function useVoiceConversationController({ } } } - return; + return false; } startAssistantSpeech(assistantSpeechHistory); + return true; } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; const currentWindowLabel = getCurrentWindow().label; @@ -1019,7 +1019,7 @@ export function useVoiceConversationController({ addErrorNotification(sessionId, errorText(cleanupError)); } onPocketSetupRequired(); - return; + return false; } let conversationStarted = false; if (exactOwnerLifecycleSurvived) { @@ -1065,6 +1065,7 @@ export function useVoiceConversationController({ } addErrorNotification(sessionId, errorText(startError)); } + return conversationStarted; } }, [ inputBackend, From 96a91d90752931a98f6661275dbedf4a65cd0bdd Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 14:25:06 -0400 Subject: [PATCH 18/18] fix(voice): preserve replacement failure outcomes --- .../useVoiceConversationController.test.ts | 32 +++++++++++++------ .../hooks/useVoiceConversationController.ts | 31 ++++++++++-------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 1d63bec31..1bbd56015 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -2245,14 +2245,14 @@ describe("voice transcript delivery coordination", () => { finishStop = resolve; }), ); - const start = vi.fn().mockResolvedValue(true); + const start = vi.fn().mockResolvedValue("completed" as const); const replacement = replaceActiveVoiceConversation({ stop, start }); await Promise.resolve(); expect(start).not.toHaveBeenCalled(); finishStop?.({ lifecycle: "stopped", sessionId: null }); - await expect(replacement).resolves.toBe(true); + await expect(replacement).resolves.toBe("completed"); expect(start).toHaveBeenCalledOnce(); }); @@ -2267,17 +2267,17 @@ describe("voice transcript delivery coordination", () => { }); const start = vi.fn(async () => { order.push("start"); - return true; + return "completed" as const; }); await expect( replaceActiveVoiceConversation({ stop, confirmTarget, start }), - ).resolves.toBe(true); + ).resolves.toBe("completed"); expect(order).toEqual(["stop", "confirm", "start"]); }); it("does not start when the target changes after stopping", async () => { - const start = vi.fn().mockResolvedValue(true); + const start = vi.fn().mockResolvedValue("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -2297,7 +2297,7 @@ describe("voice transcript delivery coordination", () => { }); it("does not start a replacement when the active call remains running", async () => { - const start = vi.fn().mockResolvedValue(true); + const start = vi.fn().mockResolvedValue("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -2307,12 +2307,12 @@ describe("voice transcript delivery coordination", () => { }), start, }), - ).resolves.toBe(false); + ).resolves.toBe("not-completed"); expect(start).not.toHaveBeenCalled(); }); it("does not start a replacement when stopping the active call fails", async () => { - const start = vi.fn().mockResolvedValue(true); + const start = vi.fn().mockResolvedValue("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -2330,9 +2330,21 @@ describe("voice transcript delivery coordination", () => { lifecycle: "stopped", sessionId: null, }), - start: vi.fn().mockResolvedValue(false), + start: vi.fn().mockResolvedValue("not-completed"), }), - ).resolves.toBe(false); + ).resolves.toBe("not-completed"); + }); + + it("preserves replacement failures that were already reported", async () => { + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockResolvedValue({ + lifecycle: "stopped", + sessionId: null, + }), + start: vi.fn().mockResolvedValue("failure-reported"), + }), + ).resolves.toBe("failure-reported"); }); it("drains retained transcripts without stealing a stopped session route", () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index b61ff5d0a..cf0ee82d6 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -124,17 +124,22 @@ export function shouldShowVoiceConversationControl(options: { : options.voiceEnabled && options.isGooseSession; } +export type VoiceConversationTransitionOutcome = + | "completed" + | "not-completed" + | "failure-reported"; + export async function replaceActiveVoiceConversation(options: { stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; confirmTarget?: () => Promise; - start: () => Promise; -}): Promise { + start: () => Promise; +}): Promise { const stopped = await options.stop(); if ( stopped.sessionId !== null || (stopped.lifecycle !== "stopped" && stopped.lifecycle !== "unavailable") ) { - return false; + return "not-completed"; } await options.confirmTarget?.(); return options.start(); @@ -900,7 +905,7 @@ export function useVoiceConversationController({ ); const startCurrentConversation = useCallback(async () => { - if (inputBackend === null) return false; + if (inputBackend === null) return "not-completed"; const readCurrentStartEligibility = () => { const current = startEligibilityRef.current; const stillEligible = @@ -916,18 +921,18 @@ export function useVoiceConversationController({ }; const startedCaptureMayContinue = () => readCurrentStartEligibility().stillEligible; - if (!startCanStillBegin()) return false; + if (!startCanStillBegin()) return "not-completed"; try { if ((await getMicrophonePermissionStatus()) === "denied") { onPocketSetupRequired(); - return false; + return "failure-reported"; } } catch { // Permission inspection is an optimization. Capture remains the source // of truth and provides the recovery path for unsupported platforms, // stale permission state, and other audio startup failures. } - if (!startCanStillBegin()) return false; + if (!startCanStillBegin()) return "not-completed"; // Do not rely on the mount effect racing ahead of the user's first // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. @@ -938,7 +943,7 @@ export function useVoiceConversationController({ try { const foregroundGeneration = await confirmVoiceConversationForegroundSession(sessionId); - if (!startCanStillBegin()) return false; + if (!startCanStillBegin()) return "not-completed"; const { current: currentStartEligibility } = readCurrentStartEligibility(); route = { @@ -979,10 +984,10 @@ export function useVoiceConversationController({ } } } - return false; + return "not-completed"; } startAssistantSpeech(assistantSpeechHistory); - return true; + return "completed"; } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; const currentWindowLabel = getCurrentWindow().label; @@ -1019,7 +1024,7 @@ export function useVoiceConversationController({ addErrorNotification(sessionId, errorText(cleanupError)); } onPocketSetupRequired(); - return false; + return "failure-reported"; } let conversationStarted = false; if (exactOwnerLifecycleSurvived) { @@ -1065,7 +1070,7 @@ export function useVoiceConversationController({ } addErrorNotification(sessionId, errorText(startError)); } - return conversationStarted; + return conversationStarted ? "completed" : "failure-reported"; } }, [ inputBackend, @@ -1236,7 +1241,7 @@ export function useVoiceConversationController({ confirmVoiceConversationForegroundSession(sessionId), start: startCurrentConversation, }); - if (!replaced) { + if (replaced === "not-completed") { addErrorNotification( sessionId, t("toolbar.voiceConversation.buddy.errors.stop"),