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 f0735ae53..fa084a9e4 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,170 @@ 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("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("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 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(); + + 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("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..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, @@ -33,10 +34,10 @@ 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; +const blockedSendRouteSessions = new Set(); let deliveryInitialized = false; const operationInFlightBySession = new Set(); let replacementOperationInFlight = false; @@ -447,6 +448,12 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { + if (blockedSendRouteSessions.has(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 = @@ -484,6 +491,11 @@ function ensureVoiceEventDeliveryInitialized() { const opportunity = await waitForVoiceDeliveryOpportunity( event.sessionId, ); + if (blockedSendRouteSessions.has(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( @@ -594,6 +606,7 @@ export interface UseVoiceConversationControllerOptions { onPocketSetupRequired: () => void; readOnly?: boolean; disabled?: boolean; + routeBlocked?: boolean; } export function useVoiceConversationController({ @@ -607,6 +620,7 @@ export function useVoiceConversationController({ onPocketSetupRequired, readOnly = false, disabled = false, + routeBlocked = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -662,12 +676,19 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - const routeIsValid = canBindVoiceSendRoute({ - enabled, - isGooseSession, - readOnly, - disabled, - }); + if (routeBlocked) { + blockedSendRouteSessions.add(sessionId); + } else { + blockedSendRouteSessions.delete(sessionId); + } + const routeIsValid = + !routeBlocked && + canBindVoiceSendRoute({ + enabled, + isGooseSession, + readOnly, + disabled, + }); const activeVoiceSessionId = status.sessionId; const routeMount = resolveVoiceRouteMount({ routeIsValid, @@ -677,7 +698,10 @@ export function useVoiceConversationController({ }); if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; - } else if (!routeIsValid && activeSendRoute?.sessionId === sessionId) { + } else if ( + (!enabled || !isGooseSession || readOnly) && + activeSendRoute?.sessionId === sessionId + ) { activeSendRoute = null; } if (routeMount.drainPending) { @@ -697,6 +721,7 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, + routeBlocked, sessionId, status.sessionId, ]); @@ -705,6 +730,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || + routeBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -732,6 +758,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + routeBlocked, sessionId, status.lifecycle, status.sessionId, 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."); } }