diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 4ac10a0bb..f663df1eb 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,13 +276,9 @@ export function ChatView({ }); }, readOnly: Boolean(readOnlyStatus), - disabled: - admissionBlocked || - controller.projectMetadataPending || - controller.isCompactingContext || - controller.isLoadingHistory || - !controller.workspaceContextReady || - controller.queue.queuedMessage !== null, + routeBlocked: voiceDeliveryTemporarilyBlocked, + routeUnavailable: voiceAdmissionPermanentlyBlocked, + 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..6d8701366 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,12 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { 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(); }); @@ -1389,8 +1394,12 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { 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 f0735ae53..1bbd56015 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, @@ -210,162 +227,688 @@ 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(); microphonePermissionMocks.getStatus.mockResolvedValue("authorized"); useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); }); - it("serializes deliveries for the same session and re-evaluates in order", async () => { - const enqueue = createVoiceTranscriptDeliveryQueue(); - const events: string[] = []; - let releaseFirst!: () => void; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const first = enqueue("session-1", async () => { - events.push("first:start"); - await firstGate; - events.push("first:end"); + 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 second = enqueue("session-1", async () => { - events.push("second:start"); - events.push("second:end"); + 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, + }); }); - await vi.waitFor(() => expect(events).toEqual(["first:start"])); - releaseFirst(); - await Promise.all([first, second]); - expect(events).toEqual([ - "first:start", - "first:end", - "second:start", - "second:end", - ]); + expect(onSend).toHaveBeenCalledWith( + "keep this route", + undefined, + undefined, + expect.objectContaining({ displayText: "keep this route" }), + ); }); - it("does not let a failed delivery poison the next queued delivery", async () => { - const enqueue = createVoiceTranscriptDeliveryQueue(); - const next = vi.fn(); - const failed = enqueue("session-1", async () => { - throw new Error("failed"); - }); - const recovered = enqueue("session-1", async () => { - next(); + 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, routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + readOnly, + routeBlocked, + }), + { initialProps: { readOnly: false, routeBlocked: false } }, + ); - await expect(failed).rejects.toThrow("failed"); - await expect(recovered).resolves.toBeUndefined(); - expect(next).toHaveBeenCalledOnce(); + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ readOnly: true, routeBlocked: 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("delivers a queued transcript after voice capture stops", async () => { - useChatStore.getState().setChatState("session-1", "streaming"); + 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: "stopped", - sessionId: null, - ownerWindowLabel: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", microphoneMuted: false, - revision: 4, + 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 } }, + ); - const opportunity = waitForVoiceDeliveryOpportunity("session-1"); - useChatStore.getState().setChatState("session-1", "idle"); - - await expect(opportunity).resolves.toBe("send"); + 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("retries the durable native transcript queue without overlapping drains", async () => { - vi.useFakeTimers(); - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; + 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 drain = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValue(undefined); - const onError = vi.fn(); + const { rerender } = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); - const stop = startPendingTranscriptRecovery(drain, onError, 500); - expect(drain).toHaveBeenCalledOnce(); + 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", + ), + ); - await vi.advanceTimersByTimeAsync(1_500); - expect(drain).toHaveBeenCalledOnce(); + rerender({ routeBlocked: true }); + act(() => useChatStore.getState().setChatState("session-1", "idle")); - release(); - await pending; - await vi.advanceTimersByTimeAsync(500); - expect(drain).toHaveBeenCalledTimes(2); + 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([]); - stop(); - await vi.advanceTimersByTimeAsync(1_000); - expect(drain).toHaveBeenCalledTimes(2); - expect(onError).not.toHaveBeenCalled(); - vi.useRealTimers(); + 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("backs off repeated recovery failures and reports them once", async () => { - vi.useFakeTimers(); - const drain = vi.fn().mockRejectedValue(new Error("rejected")); - const onError = vi.fn(); - - const stop = startPendingTranscriptRecovery(drain, onError, 100); - await vi.runAllTicks(); - expect(drain).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledOnce(); + it("defers transcripts until admission is unblocked", 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 vi.advanceTimersByTimeAsync(100); - expect(drain).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(199); - expect(drain).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - expect(drain).toHaveBeenCalledTimes(3); - expect(onError).toHaveBeenCalledOnce(); + 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("waiting for its bound chat"); + expect(onSend).not.toHaveBeenCalled(); - stop(); - vi.useRealTimers(); + rerender({ routeBlocked: false }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-after-admission-block", + text: "deliver after unblock", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + expect(onSend).toHaveBeenCalledWith( + "deliver after unblock", + undefined, + undefined, + expect.objectContaining({ displayText: "deliver after unblock" }), + ); }); - it("binds routes only for enabled writable Goose sessions", () => { - expect( - canBindVoiceSendRoute({ + 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, - readOnly: false, - disabled: false, - }), - ).toBe(true); - expect( - canBindVoiceSendRoute({ - enabled: true, - isGooseSession: false, - readOnly: false, - disabled: false, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked: false, }), - ).toBe(false); - expect( - canBindVoiceSendRoute({ + ); + renderHook(() => + useVoiceConversationController({ + sessionId: "session-multi-view", + onSend: duplicateSend, enabled: true, isGooseSession: true, - readOnly: true, - disabled: false, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked: true, }), - ).toBe(false); - expect( - canBindVoiceSendRoute({ - enabled: false, - isGooseSession: true, - readOnly: false, - disabled: false, + ); + + 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, }), - ).toBe(false); + ).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("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("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-blocked", + 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(); + 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(replacementSend).toHaveBeenCalledOnce(); + }); + + it("serializes deliveries for the same session and re-evaluates in order", async () => { + const enqueue = createVoiceTranscriptDeliveryQueue(); + const events: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = enqueue("session-1", async () => { + events.push("first:start"); + await firstGate; + events.push("first:end"); + }); + const second = enqueue("session-1", async () => { + events.push("second:start"); + events.push("second:end"); + }); + + await vi.waitFor(() => expect(events).toEqual(["first:start"])); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual([ + "first:start", + "first:end", + "second:start", + "second:end", + ]); + }); + + it("does not let a failed delivery poison the next queued delivery", async () => { + const enqueue = createVoiceTranscriptDeliveryQueue(); + const next = vi.fn(); + const failed = enqueue("session-1", async () => { + throw new Error("failed"); + }); + const recovered = enqueue("session-1", async () => { + next(); + }); + + await expect(failed).rejects.toThrow("failed"); + await expect(recovered).resolves.toBeUndefined(); + expect(next).toHaveBeenCalledOnce(); + }); + + it("delivers a queued transcript after voice capture stops", async () => { + useChatStore.getState().setChatState("session-1", "streaming"); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "stopped", + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 4, + }, + }); + + const opportunity = waitForVoiceDeliveryOpportunity("session-1"); + useChatStore.getState().setChatState("session-1", "idle"); + + await expect(opportunity).resolves.toBe("send"); + }); + + it("retries the durable native transcript queue without overlapping drains", async () => { + vi.useFakeTimers(); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const drain = vi + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValue(undefined); + const onError = vi.fn(); + + const stop = startPendingTranscriptRecovery(drain, onError, 500); + expect(drain).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(1_500); + expect(drain).toHaveBeenCalledOnce(); + + release(); + await pending; + await vi.advanceTimersByTimeAsync(500); + expect(drain).toHaveBeenCalledTimes(2); + + stop(); + await vi.advanceTimersByTimeAsync(1_000); + expect(drain).toHaveBeenCalledTimes(2); + expect(onError).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("backs off repeated recovery failures and reports them once", async () => { + vi.useFakeTimers(); + const drain = vi.fn().mockRejectedValue(new Error("rejected")); + const onError = vi.fn(); + + const stop = startPendingTranscriptRecovery(drain, onError, 100); + await vi.runAllTicks(); + expect(drain).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(100); + expect(drain).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(199); + expect(drain).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(drain).toHaveBeenCalledTimes(3); + expect(onError).toHaveBeenCalledOnce(); + + stop(); + vi.useRealTimers(); + }); + + it("binds routes only for enabled writable Goose sessions", () => { + expect( + canBindVoiceSendRoute({ + enabled: true, + isGooseSession: true, + readOnly: false, + disabled: false, + }), + ).toBe(true); + expect( + canBindVoiceSendRoute({ + enabled: true, + isGooseSession: false, + readOnly: false, + disabled: false, + }), + ).toBe(false); + expect( + canBindVoiceSendRoute({ + enabled: true, + isGooseSession: true, + readOnly: true, + disabled: false, + }), + ).toBe(false); + expect( + canBindVoiceSendRoute({ + enabled: false, + isGooseSession: true, + readOnly: false, + disabled: false, + }), + ).toBe(false); }); it("starts a requested voice conversation only for its ready enabled Goose chat", () => { @@ -1012,7 +1555,323 @@ describe("voice transcript delivery coordination", () => { expect(refreshStatus).toHaveBeenCalledTimes(2); }); - it("surfaces a rejected start when owner microphone reconciliation still fails", async () => { + it("surfaces a rejected start when owner microphone reconciliation still fails", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const running = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi + .fn() + .mockResolvedValueOnce(stopped) + .mockRejectedValueOnce(new Error("microphone unavailable")), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: running, + uiState: "error", + error: "renderer reconciliation failed", + }); + }); + startRequest.reject(new Error("renderer reconciliation failed")); + await toggling; + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(useChatStore.getState().messagesBySession["session-a"]).toHaveLength( + 1, + ); + }); + + it("opens Voice settings without starting when microphone access is denied", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const start = vi.fn(); + const onVoiceSetupRequired = vi.fn(); + microphonePermissionMocks.getStatus.mockResolvedValue("denied"); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: onVoiceSetupRequired, + }), + ); + + await act(async () => { + await result.current.onToggle(); + }); + + expect(start).not.toHaveBeenCalled(); + expect(onVoiceSetupRequired).toHaveBeenCalledOnce(); + }); + + it("opens Voice settings when microphone capture cannot start", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const running = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const onVoiceSetupRequired = vi.fn(); + const stop = vi.fn().mockImplementation(async () => { + useVoiceConversationStore.setState({ + status: stopped, + uiState: "error", + error: "The backend already stopped", + }); + throw new Error("The backend already stopped"); + }); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockImplementation(async () => { + useVoiceConversationStore.setState({ + status: running, + uiState: "error", + error: "Permission denied", + }); + throw new VoiceMicrophoneCaptureError( + new DOMException("Permission denied", "NotAllowedError"), + ); + }), + stop, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: onVoiceSetupRequired, + }), + ); + + await act(async () => { + await result.current.onToggle(); + }); + + expect(stop).toHaveBeenCalledOnce(); + expect(onVoiceSetupRequired).toHaveBeenCalledOnce(); + expect(useVoiceConversationStore.getState()).toMatchObject({ + status: stopped, + uiState: "off", + error: null, + }); + }); + + it("does not activate speech when a non-owner mounts an already-running session", () => { + const running = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-owner", + microphoneMuted: false, + revision: 2, + }; + tauriWindowMocks.label = "session-window-mirror"; + useVoiceConversationStore.setState({ + status: running, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + }); + + renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + }); + + it("does not activate speech for another window's same-session lifecycle", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const winner = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-winner", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + tauriWindowMocks.label = "session-window-loser"; + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: winner, + uiState: "listening", + }); + }); + startRequest.reject(new Error("lost same-session start")); + await toggling; + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + 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("does not start after admission becomes temporarily blocked", async () => { const stopped = { available: true, unavailableReason: null, @@ -1022,60 +1881,63 @@ describe("voice transcript delivery coordination", () => { microphoneMuted: false, revision: 1, }; - const running = { + const foregroundRequest = deferred(); + const start = vi.fn().mockResolvedValue({ ...stopped, - lifecycle: "running" as const, + lifecycle: "starting" as const, sessionId: "session-a", ownerWindowLabel: "main", revision: 2, - }; - const startRequest = deferred(); - const start = vi.fn().mockReturnValue(startRequest.promise); + }); + voiceApiMocks.confirmForegroundSession.mockReturnValue( + foregroundRequest.promise, + ); useVoiceConversationStore.setState({ status: stopped, uiState: "off", hydrated: true, init: vi.fn().mockResolvedValue(undefined), - refreshStatus: vi - .fn() - .mockResolvedValueOnce(stopped) - .mockRejectedValueOnce(new Error("microphone unavailable")), - drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), start, }); - const { result } = renderHook(() => - useVoiceConversationController({ - sessionId: "session-a", - onSend: vi.fn().mockResolvedValue(true), - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: vi.fn(), - }), + 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(result.current.onToggle()); + toggling = Promise.resolve(control.result.current.onToggle()); }); - await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(voiceApiMocks.confirmForegroundSession).toHaveBeenCalledOnce(), + ); act(() => { - useVoiceConversationStore.setState({ - status: running, - uiState: "error", - error: "renderer reconciliation failed", - }); + control.rerender({ routeBlocked: true }); + }); + await act(async () => { + foregroundRequest.resolve(1); + await toggling; }); - startRequest.reject(new Error("renderer reconciliation failed")); - await toggling; + expect(start).not.toHaveBeenCalled(); expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); - expect(useChatStore.getState().messagesBySession["session-a"]).toHaveLength( - 1, - ); }); - it("opens Voice settings without starting when microphone access is denied", async () => { + it("stops a native start when admission becomes unavailable in flight", async () => { const stopped = { available: true, unavailableReason: null, @@ -1085,38 +1947,59 @@ describe("voice transcript delivery coordination", () => { microphoneMuted: false, revision: 1, }; - const start = vi.fn(); - const onVoiceSetupRequired = vi.fn(); - microphonePermissionMocks.getStatus.mockResolvedValue("denied"); + 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), - drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), start, + stop, }); - const { result } = renderHook(() => - useVoiceConversationController({ - sessionId: "session-a", - onSend: vi.fn().mockResolvedValue(true), - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: onVoiceSetupRequired, - }), + 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 () => { - await result.current.onToggle(); + control.rerender({ routeUnavailable: true }); + startRequest.resolve(starting); + await toggling; }); - expect(start).not.toHaveBeenCalled(); - expect(onVoiceSetupRequired).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); }); - it("opens Voice settings when microphone capture cannot start", async () => { + it("keeps a native start alive through a temporary delivery block", async () => { const stopped = { available: true, unavailableReason: null, @@ -1126,100 +2009,63 @@ describe("voice transcript delivery coordination", () => { microphoneMuted: false, revision: 1, }; - const running = { + const starting = { ...stopped, - lifecycle: "running" as const, + lifecycle: "starting" as const, sessionId: "session-a", ownerWindowLabel: "main", revision: 2, }; - const onVoiceSetupRequired = vi.fn(); - const stop = vi.fn().mockImplementation(async () => { - useVoiceConversationStore.setState({ - status: stopped, - uiState: "error", - error: "The backend already stopped", - }); - throw new Error("The backend already stopped"); + 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), - drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), - start: vi.fn().mockImplementation(async () => { - useVoiceConversationStore.setState({ - status: running, - uiState: "error", - error: "Permission denied", - }); - throw new VoiceMicrophoneCaptureError( - new DOMException("Permission denied", "NotAllowedError"), - ); - }), + start, stop, }); - const { result } = renderHook(() => - useVoiceConversationController({ - sessionId: "session-a", - onSend: vi.fn().mockResolvedValue(true), - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: onVoiceSetupRequired, - }), + 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 } }, ); - await act(async () => { - await result.current.onToggle(); - }); - - expect(stop).toHaveBeenCalledOnce(); - expect(onVoiceSetupRequired).toHaveBeenCalledOnce(); - expect(useVoiceConversationStore.getState()).toMatchObject({ - status: stopped, - uiState: "off", - error: null, + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(control.result.current.onToggle()); }); - }); - - it("does not activate speech when a non-owner mounts an already-running session", () => { - const running = { - available: true, - unavailableReason: null, - lifecycle: "running" as const, - sessionId: "session-a", - ownerWindowLabel: "session-window-owner", - microphoneMuted: false, - revision: 2, - }; - tauriWindowMocks.label = "session-window-mirror"; - useVoiceConversationStore.setState({ - status: running, - uiState: "listening", - hydrated: true, - init: vi.fn().mockResolvedValue(undefined), - drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await act(async () => { + control.rerender({ routeBlocked: true }); + startRequest.resolve(starting); + await toggling; }); - renderHook(() => - useVoiceConversationController({ - sessionId: "session-a", - onSend: vi.fn().mockResolvedValue(true), - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: vi.fn(), - }), - ); - - expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); - expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledOnce(); }); - it("does not activate speech for another window's same-session lifecycle", async () => { + it("does not stop a replacement lifecycle after a stale start returns", async () => { const stopped = { available: true, unavailableReason: null, @@ -1229,52 +2075,66 @@ describe("voice transcript delivery coordination", () => { microphoneMuted: false, revision: 1, }; - const winner = { + const staleStarting = { ...stopped, - lifecycle: "running" as const, + lifecycle: "starting" as const, sessionId: "session-a", - ownerWindowLabel: "session-window-winner", + ownerWindowLabel: "main", revision: 2, }; - const startRequest = deferred(); - const start = vi.fn().mockReturnValue(startRequest.promise); - tauriWindowMocks.label = "session-window-loser"; + 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), - drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), start, + stop, }); - const { result } = renderHook(() => - useVoiceConversationController({ - sessionId: "session-a", - onSend: vi.fn().mockResolvedValue(true), - enabled: true, - isGooseSession: true, - pocketReady: true, - onPocketSetupRequired: vi.fn(), - }), + 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(result.current.onToggle()); + toggling = Promise.resolve(control.result.current.onToggle()); }); await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); - act(() => { - useVoiceConversationStore.setState({ - status: winner, - uiState: "listening", - }); + await act(async () => { + control.rerender({ routeUnavailable: true }); + startRequest.resolve(staleStarting); + await toggling; }); - startRequest.reject(new Error("lost same-session start")); - await toggling; + expect(stop).not.toHaveBeenCalled(); expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); - expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); it("deduplicates concurrent controls for the same session", async () => { @@ -1385,14 +2245,14 @@ describe("voice transcript delivery coordination", () => { finishStop = resolve; }), ); - const start = vi.fn().mockResolvedValue(undefined); + 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(); }); @@ -1407,16 +2267,17 @@ describe("voice transcript delivery coordination", () => { }); const start = vi.fn(async () => { order.push("start"); + 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(undefined); + const start = vi.fn().mockResolvedValue("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -1436,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("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -1446,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(undefined); + const start = vi.fn().mockResolvedValue("completed" as const); await expect( replaceActiveVoiceConversation({ @@ -1462,6 +2323,30 @@ 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("not-completed"), + }), + ).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", () => { expect( resolveVoiceRouteMount({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 658223c6f..cf0ee82d6 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, @@ -29,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 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; +const mountedSendRoutes = new Map(); let deliveryInitialized = false; const operationInFlightBySession = new Set(); let replacementOperationInFlight = false; @@ -59,6 +63,27 @@ export function createVoiceTranscriptDeliveryQueue() { const enqueueVoiceTranscriptDelivery = createVoiceTranscriptDeliveryQueue(); +function activeRouteIsBlocked(sessionId: string): boolean { + return activeSendRoute?.sessionId === sessionId && activeSendRoute.blocked; +} + +function releaseVoiceSendRoute( + owner: symbol, + preserveActiveRoute = false, +): VoiceSendRoute | null { + const released = mountedSendRoutes.get(owner); + mountedSendRoutes.delete(owner); + if (activeSendRoute?.owner !== owner) return activeSendRoute; + + const replacement = + [...mountedSendRoutes.values()].find( + (route) => route.sessionId === released?.sessionId && route.canClaim, + ) ?? null; + activeSendRoute = + replacement ?? (preserveActiveRoute ? (released ?? null) : null); + return activeSendRoute; +} + export function canBindVoiceSendRoute(options: { enabled: boolean; isGooseSession: boolean; @@ -99,21 +124,25 @@ 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?.(); - await options.start(); - return true; + return options.start(); } export function shouldSuppressVoiceConversationControls(options: { @@ -447,6 +476,12 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { + if (activeRouteIsBlocked(event.sessionId)) { + throw new VoiceTranscriptDeferredError( + "Voice transcript is waiting for its bound chat to become available.", + ); + } + const route = activeSendRoute; if (!route || route.sessionId !== event.sessionId) { const message = @@ -472,24 +507,29 @@ 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. const opportunity = await waitForVoiceDeliveryOpportunity( event.sessionId, ); + if (activeRouteIsBlocked(event.sessionId)) { + throw new VoiceTranscriptDeferredError( + "Voice transcript is waiting for its bound chat to become available.", + ); + } const currentRoute = activeSendRoute; if (!currentRoute || currentRoute.sessionId !== event.sessionId) { throw new Error( "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" @@ -530,6 +570,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 && @@ -594,6 +644,8 @@ export interface UseVoiceConversationControllerOptions { onPocketSetupRequired: () => void; readOnly?: boolean; disabled?: boolean; + routeBlocked?: boolean; + routeUnavailable?: boolean; } export function useVoiceConversationController({ @@ -607,6 +659,8 @@ export function useVoiceConversationController({ onPocketSetupRequired, readOnly = false, disabled = false, + routeBlocked = false, + routeUnavailable = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -640,6 +694,46 @@ 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 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, + sessionId, + inputBackend, + eligible: + inputBackend !== null && + enabled && + isGooseSession && + !readOnly && + !routeUnavailable, + deliveryBlocked, + }; + const drainPendingTranscriptsRef = useRef(drainPendingTranscripts); + drainPendingTranscriptsRef.current = drainPendingTranscripts; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -662,25 +756,46 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - const routeIsValid = 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 }; - } else if (!routeIsValid && activeSendRoute?.sessionId === sessionId) { - activeSendRoute = null; + if (activeSendRoute?.owner === routeOwner) { + if (routeCanPersist) { + activeSendRoute = registeredRoute; + } else { + releaseVoiceSendRoute(routeOwner); + mountedSendRoutes.set(routeOwner, registeredRoute); + } + } else if ( + routeMount.claimRoute && + (activeSendRoute === null || + activeSendRoute.sessionId !== activeVoiceSessionId || + !mountedSendRoutes.has(activeSendRoute.owner)) + ) { + activeSendRoute = registeredRoute; } - if (routeMount.drainPending) { + if ( + routeMount.drainPending && + activeSendRoute?.owner === routeOwner && + !activeSendRoute.blocked + ) { const routeSessionId = activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( @@ -697,14 +812,32 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, + deliveryBlocked, + routeOwner, + routeUnavailable, sessionId, status.sessionId, ]); + useEffect( + () => () => { + const replacement = releaseVoiceSendRoute(routeOwner, true); + if (!replacement || replacement.blocked) return; + void drainPendingTranscriptsRef + .current(replacement.sessionId, transcriptAlreadyInChat) + .catch((drainError) => { + addErrorNotification(replacement.sessionId, errorText(drainError)); + }); + }, + [routeOwner], + ); + useEffect(() => { if ( status.lifecycle !== "running" || status.sessionId !== sessionId || + deliveryBlocked || + routeUnavailable || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -732,6 +865,8 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + deliveryBlocked, + routeUnavailable, sessionId, status.lifecycle, status.sessionId, @@ -770,30 +905,89 @@ export function useVoiceConversationController({ ); const startCurrentConversation = useCallback(async () => { - if (inputBackend === null) return; + if (inputBackend === null) return "not-completed"; + const readCurrentStartEligibility = () => { + const current = startEligibilityRef.current; + const stillEligible = + current.eligible && + current.routeOwner === routeOwner && + current.sessionId === sessionId && + current.inputBackend === inputBackend; + return { current, stillEligible }; + }; + const startCanStillBegin = () => { + const { current, stillEligible } = readCurrentStartEligibility(); + return stillEligible && !current.deliveryBlocked; + }; + const startedCaptureMayContinue = () => + readCurrentStartEligibility().stillEligible; + if (!startCanStillBegin()) return "not-completed"; try { if ((await getMicrophonePermissionStatus()) === "denied") { onPocketSetupRequired(); - return; + 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 "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. ensureVoiceEventDeliveryInitialized(); const assistantSpeechHistory = captureNativeAssistantSpeechHistory(sessionId); - const route = { sessionId, send: onSend }; - activeSendRoute = route; + let route: VoiceSendRoute | null = null; try { const foregroundGeneration = await confirmVoiceConversationForegroundSession(sessionId); - await start(sessionId, inputBackend, foregroundGeneration); + if (!startCanStillBegin()) return "not-completed"; + const { current: currentStartEligibility } = + readCurrentStartEligibility(); + route = { + owner: routeOwner, + sessionId, + send: onSend, + blocked: currentStartEligibility.deliveryBlocked, + canClaim: true, + }; + mountedSendRoutes.set(routeOwner, route); + activeSendRoute = route; + const startedStatus = await start( + sessionId, + inputBackend, + foregroundGeneration, + ); + if (!startedCaptureMayContinue()) { + if (activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); + } + if ( + startedStatus.sessionId === sessionId && + startedStatus.lifecycle !== "stopped" && + startedStatus.lifecycle !== "unavailable" + ) { + 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 "not-completed"; + } startAssistantSpeech(assistantSpeechHistory); + return "completed"; } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; const currentWindowLabel = getCurrentWindow().label; @@ -802,8 +996,8 @@ export function useVoiceConversationController({ backendStatus.sessionId === sessionId && backendStatus.ownerWindowLabel === currentWindowLabel; if (isVoiceMicrophoneCaptureError(startError)) { - if (activeSendRoute?.sessionId === route.sessionId) { - activeSendRoute = null; + if (route && activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); let cleanupError: unknown = null; @@ -830,7 +1024,7 @@ export function useVoiceConversationController({ addErrorNotification(sessionId, errorText(cleanupError)); } onPocketSetupRequired(); - return; + return "failure-reported"; } let conversationStarted = false; if (exactOwnerLifecycleSurvived) { @@ -871,16 +1065,18 @@ export function useVoiceConversationController({ } } if (!conversationStarted) { - if (activeSendRoute?.sessionId === route.sessionId) { - activeSendRoute = null; + if (route && activeSendRoute?.owner === route.owner) { + releaseVoiceSendRoute(route.owner); } addErrorNotification(sessionId, errorText(startError)); } + return conversationStarted ? "completed" : "failure-reported"; } }, [ inputBackend, onPocketSetupRequired, onSend, + routeOwner, sessionId, start, startAssistantSpeech, @@ -993,7 +1189,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 () => { @@ -1044,7 +1241,7 @@ export function useVoiceConversationController({ confirmVoiceConversationForegroundSession(sessionId), start: startCurrentConversation, }); - if (!replaced) { + if (replaced === "not-completed") { addErrorNotification( sessionId, t("toolbar.voiceConversation.buddy.errors.stop"), 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."); } }