Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/features/chat/ui/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function ChatView({
});
},
readOnly: Boolean(readOnlyStatus),
routeBlocked: admissionBlocked,
disabled:
admissionBlocked ||
controller.projectMetadataPending ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => void | Promise<void>)
| undefined,
}));

vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ label: tauriWindowMocks.label }),
Expand All @@ -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<string, unknown>) => void | Promise<void>,
) => {
voiceStoreMocks.subscriber = subscriber;
return () => undefined;
},
}));

import {
canBindVoiceSendRoute,
canReplaceActiveVoiceConversation,
Expand Down Expand Up @@ -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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { steerPromptInSession } from "@/features/chat/lib/steerCore";
import {
subscribeToVoiceConversationEvents,
useVoiceConversationStore,
VoiceTranscriptDeferredError,
} from "../stores/voiceConversationStore";
import {
captureNativeAssistantSpeechHistory,
Expand All @@ -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<string>();
let deliveryInitialized = false;
const operationInFlightBySession = new Set<string>();
let replacementOperationInFlight = false;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -594,6 +606,7 @@ export interface UseVoiceConversationControllerOptions {
onPocketSetupRequired: () => void;
readOnly?: boolean;
disabled?: boolean;
routeBlocked?: boolean;
}

export function useVoiceConversationController({
Expand All @@ -607,6 +620,7 @@ export function useVoiceConversationController({
onPocketSetupRequired,
readOnly = false,
disabled = false,
routeBlocked = false,
}: UseVoiceConversationControllerOptions): ChatInputVoiceConversation {
const { t } = useTranslation("chat");
const siriVoiceRef = useRef(siriVoice);
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -697,6 +721,7 @@ export function useVoiceConversationController({
isGooseSession,
onSend,
readOnly,
routeBlocked,
sessionId,
status.sessionId,
]);
Expand All @@ -705,6 +730,7 @@ export function useVoiceConversationController({
if (
status.lifecycle !== "running" ||
status.sessionId !== sessionId ||
routeBlocked ||
!canBindVoiceSendRoute({
enabled,
isGooseSession,
Expand Down Expand Up @@ -732,6 +758,7 @@ export function useVoiceConversationController({
enabled,
isGooseSession,
readOnly,
routeBlocked,
sessionId,
status.lifecycle,
status.sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading