FN-8777: scope voice dictation to selected projects

Keep voice dictation sessions and requests isolated to the active dashboard project.

- Append the encoded project ID to voice status, session, transcription, and cleanup requests.
- Add real-route and composer coverage for scoped sessions and project changes.
- Document the project boundary and add a patch changeset.

Files changed:
 .changeset/fn-8777-voice-entry-e2e.md              |   7 +
 docs/dashboard-guide.md                            |   5 +
 .../__tests__/voice-dictation-composers.test.tsx   |  19 ++-
 .../app/hooks/__tests__/useVoiceDictation.test.tsx | 149 ++++++++++++++++++++-
 packages/dashboard/app/hooks/useVoiceDictation.ts  |  44 ++++--
 .../routes/__tests__/register-voice-routes.test.ts |  28 ++++
 6 files changed, 238 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-8777

Fusion-Task-Lineage: 65390f79-3e29-4759-88c6-275b1c9f17d4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 01:24:30 -07:00
parent f344715df5
commit c10a880fc9
6 changed files with 238 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep voice dictation requests scoped to the selected project.
category: fix
dev: Voice session create, transcription, and cleanup now share the status request project identity.

View File

@@ -45,6 +45,11 @@ Settings form changes save automatically after a short pause. The footer no long
**Settings → Voice Input** is visible in both Basic and Advanced settings. Voice mode is off by default; enabling it is an explicit project preference. The same section shows the locally managed Parakeet v3 model and lets an operator download or remove it. Its upstream `sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` archive is about 465 MB and Fusion verifies its pinned SHA-256 before installing it; an unpinned or mismatched download is refused. Download progress is polled only while the model is downloading. The toggle becomes interactive only when the model is installed and Fusion can load the optional `sherpa-onnx-node` runtime. If Settings reports a missing module, a platform runtime load failure, or an incompatible runtime, reinstall a supported Fusion package for the current platform and reopen Settings. When sherpa-onnx is unavailable, Settings preserves any saved enabled preference but presents voice mode as backend-enforced disabled with an explanation. If status cannot be determined, the section fails closed: voice mode stays disabled and model actions are not shown until status is available.
<!-- FNXC:VoiceInputDocs 2026-08-04-07:37: Voice dictation is a project-scoped capture session, so the operator guide must describe the selected-project boundary and teardown guarantees verified by the client-to-route regression. -->
When Voice Input is available, every microphone capture remains scoped to the dashboard's selected project: status, session creation, PCM transcription, finalization, and cleanup all use that project identity. The mic is shown only after that project's voice preference is enabled, the Parakeet model is installed, and the browser supports microphone and AudioWorklet capture. Unsupported browsers, denied microphone permission, unavailable runtime/model, and status failures show no microphone control.
Start dictation with the microphone beside any supported composer. Partial speech appears at the current caret or replaces the current selection; the final transcript replaces that preview without disturbing surrounding text or another composer's selection. Stop, a project change, closing a composer, and transcription errors immediately release browser capture resources and close the project's backend session. If transcription cannot finish, already-entered text remains intact and the mic returns to a safe idle/error state.
## Reset Settings
<!-- FNXC:SettingsResetDocs 2026-07-04-00:00: Reset Settings is a DESTRUCTIVE action. Document both choices, the scope-precision guarantee, and which sections are excluded so operators understand exactly what a reset does and does not touch before they click it.

View File

@@ -29,15 +29,19 @@ let voice = {
stop: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
};
const voiceListeners = new Set<() => void>();
const voiceProjectIds: Array<string | undefined> = [];
vi.mock("../../hooks/useVoiceDictation", async () => {
const React = await import("react");
return {
useVoiceDictation: () => React.useSyncExternalStore(
useVoiceDictation: (projectId?: string) => {
voiceProjectIds.push(projectId);
return React.useSyncExternalStore(
(listener) => { voiceListeners.add(listener); return () => voiceListeners.delete(listener); },
() => voice,
() => voice,
),
);
},
};
});
@@ -154,11 +158,20 @@ async function exerciseRealComposer(renderSurface: () => ReturnType<typeof rende
describe("voice dictation composer inventory", () => {
beforeEach(async () => {
vi.clearAllMocks(); activeRoom = null;
vi.clearAllMocks(); activeRoom = null; voiceProjectIds.length = 0;
await act(async () => { setVoice({ enabled: true, supported: true, state: "idle", partialText: "", finalText: "", error: undefined }); });
});
afterEach(cleanup);
it("passes explicit project identity while preserving default-project composers", () => {
for (const surface of primarySurfaceRenders) {
const view = surface.render();
view.unmount();
}
expect(voiceProjectIds).toContain("project-1");
expect(voiceProjectIds).toContain(undefined);
});
it("opens the reachable shared ChatView composer from QuickChatFAB", () => {
render(<QuickChatVoicePath />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));

View File

@@ -1,11 +1,19 @@
import express from "express";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRef, useState } from "react";
import { createRegisterVoiceRoutes } from "../../../src/routes/register-voice-routes";
import type { ApiRoutesContext } from "../../../src/routes/types";
import { useComposerDictation } from "../useComposerDictation";
import { useVoiceDictation } from "../useVoiceDictation";
import { __resetVoiceAvailabilityCache } from "../useVoiceAvailability";
function Harness() {
const voice = useVoiceDictation();
const nativeFetch = globalThis.fetch.bind(globalThis);
function Harness({ projectId }: { projectId?: string }) {
const voice = useVoiceDictation(projectId);
return <>
<output data-testid="voice">{JSON.stringify({ enabled: voice.enabled, supported: voice.supported, partialText: voice.partialText, finalText: voice.finalText })}</output>
<button onClick={() => void voice.start()}>start</button>
@@ -13,6 +21,17 @@ function Harness() {
</>;
}
function ControlledComposer({ projectId }: { projectId: string }) {
const [value, setValue] = useState("before-after");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { micProps } = useComposerDictation({ textareaRef, value, onChange: setValue, projectId });
return <>
<textarea aria-label="Voice composer" ref={textareaRef} value={value} onChange={(event) => setValue(event.target.value)} />
<button aria-label="Start voice dictation" onClick={() => void micProps.start()}>start</button>
<button aria-label="Stop voice dictation" onClick={() => void micProps.stop()}>stop</button>
</>;
}
function availableResponses() {
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = String(input);
@@ -45,6 +64,85 @@ describe("useVoiceDictation", () => {
});
afterEach(() => vi.useRealTimers());
it("drives the selected-project composer through the real route and recognizer seams", async () => {
const { port, tracks } = installAudioCapture();
const app = express();
const router = express.Router();
const close = vi.fn();
const acceptChunk = vi.fn((_audio: Buffer, options: { final: boolean }) => options.final
? { text: "final transcript" }
: { partial: "partial transcript" });
const requests: string[] = [];
let clientSessionId: string | undefined;
const clientSessionIds: string[] = [];
app.use((req, res, next) => {
requests.push(`${req.method} ${req.originalUrl}`);
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (req.method === "POST" && req.path === "/voice/session") {
clientSessionId = (body as { sessionId?: string }).sessionId;
if (clientSessionId) clientSessionIds.push(clientSessionId);
}
return json(body);
}) as typeof res.json;
next();
});
app.use("/api", router);
createRegisterVoiceRoutes({
manager: { getState: async () => ({ status: "installed" as const, installedPath: "/model" }), peekState: () => ({ status: "installed" as const, installedPath: "/model" }), scheduleDownload: () => ({ accepted: false as const, state: { status: "error" as const } }), remove: async () => {}, download: async () => ({ status: "installed" as const }), subscribe: () => () => {} },
service: { getRuntimeStatus: async () => ({ status: "available" as const }), createSession: async () => ({ acceptChunk, finish: () => ({ text: "unused" }), close }) },
})({ router, getScopedStore: async () => ({ getSettings: async () => ({ voiceInput: { enabled: true } }), getGlobalSettingsStore: () => ({ getSettings: async () => ({}) }) }), getProjectIdFromRequest: (request) => typeof request.query.projectId === "string" ? request.query.projectId : undefined } as unknown as ApiRoutesContext);
const server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
vi.mocked(fetch).mockImplementation((input, init) => nativeFetch(`${origin}${String(input)}`, init));
/*
* FNXC:VoiceInput 2026-08-04-07:49:
* The regression must cross the real controlled-composer, browser-capture, and Express
* registrar seams. A response-only fetch mock cannot prove selected-project session ownership.
*/
const view = render(<ControlledComposer projectId="voice-project" />);
await waitFor(() => expect(requests).toContain("GET /api/voice/status?projectId=voice-project"));
const textarea = screen.getByLabelText("Voice composer") as HTMLTextAreaElement;
textarea.setSelectionRange("before".length, "before".length);
fireEvent.click(screen.getByRole("button", { name: "Start voice dictation" }));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
await waitFor(() => expect(requests).toContain("POST /api/voice/session?projectId=voice-project"));
await waitFor(() => expect(clientSessionId).toBeDefined());
const foreign = await nativeFetch(`${origin}/api/voice/transcribe?projectId=other-project`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: clientSessionId, audio: "AAA=", sequence: 0, final: false }) });
expect(foreign.status).toBe(404);
expect(await foreign.json()).toEqual({ error: "unknown-session" });
await act(async () => { port.onmessage?.({ data: new ArrayBuffer(6_400) } as MessageEvent<ArrayBuffer>); });
await waitFor(() => expect(textarea).toHaveValue("beforepartial transcript-after"));
fireEvent.click(screen.getByRole("button", { name: "Stop voice dictation" }));
await waitFor(() => expect(textarea).toHaveValue("beforefinal transcript-after"));
await waitFor(() => expect(requests.filter((request) => request.startsWith("DELETE /api/voice/session/") && request.endsWith("?projectId=voice-project"))).toHaveLength(1));
expect(requests).toEqual(expect.arrayContaining([
"GET /api/voice/status?projectId=voice-project",
"POST /api/voice/session?projectId=voice-project",
"POST /api/voice/transcribe?projectId=voice-project",
]));
expect(acceptChunk).toHaveBeenNthCalledWith(1, expect.any(Buffer), { final: false });
expect(acceptChunk).toHaveBeenNthCalledWith(2, expect.any(Buffer), { final: true });
expect(close).toHaveBeenCalledOnce();
expect(tracks[0].stop).toHaveBeenCalledOnce();
// Start a distinct real registrar session, then unmount while capture is active rather than
// after stop has already finalized it. This proves unmount owns exactly one scoped cleanup.
fireEvent.click(screen.getByRole("button", { name: "Start voice dictation" }));
await waitFor(() => expect(clientSessionIds).toHaveLength(2));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
const activeSessionId = clientSessionIds[1];
view.unmount();
await waitFor(() => expect(requests.filter((request) => request === `DELETE /api/voice/session/${activeSessionId}?projectId=voice-project`)).toHaveLength(1));
expect(close).toHaveBeenCalledTimes(2);
expect(tracks[0].stop).toHaveBeenCalledTimes(2);
expect(requests.filter((request) => request.startsWith("DELETE /api/voice/session/"))).toHaveLength(2);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("fails closed while status is pending or fails", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("offline"));
render(<Harness />);
@@ -71,6 +169,53 @@ describe("useVoiceDictation", () => {
expect(screen.getByTestId("voice").textContent).toContain('"supported":false');
});
it("keeps every session request in the selected project's scope", async () => {
const { port } = installAudioCapture();
const projectId = "voice project/&";
const scope = "?projectId=voice%20project%2F%26";
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = String(input);
if (url === `/api/voice/status${scope}`) return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
if (url === `/api/voice/session${scope}`) return new Response(JSON.stringify({ sessionId: "session-1" }), { status: 201 });
if (url === `/api/voice/transcribe${scope}`) return new Response(JSON.stringify({ partial: "partial", final: false }));
if (url === `/api/voice/session/session-1${scope}` && init?.method === "DELETE") return new Response("{}");
throw new Error(`Unexpected request ${url}`);
});
const view = render(<Harness projectId={projectId} />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
await act(async () => { port.onmessage?.({ data: new ArrayBuffer(6_400) } as MessageEvent<ArrayBuffer>); });
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"partialText":"partial"'));
view.unmount();
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url, init]) => url === `/api/voice/session/session-1${scope}` && init?.method === "DELETE")).toBe(true));
expect(vi.mocked(fetch).mock.calls.map(([url]) => String(url))).toEqual(expect.arrayContaining([
`/api/voice/status${scope}`,
`/api/voice/session${scope}`,
`/api/voice/transcribe${scope}`,
`/api/voice/session/session-1${scope}`,
]));
});
it("deletes the old scoped session when the selected project changes", async () => {
const { port, tracks } = installAudioCapture();
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = String(input);
if (url.startsWith("/api/voice/status")) return new Response(JSON.stringify({ enabled: true, runtime: { status: "available" }, model: { status: "installed" } }));
if (url === "/api/voice/session?projectId=project-a") return new Response(JSON.stringify({ sessionId: "session-a" }), { status: 201 });
if (url === "/api/voice/session/session-a?projectId=project-a" && init?.method === "DELETE") return new Response("{}");
throw new Error(`Unexpected request ${url}`);
});
const view = render(<Harness projectId="project-a" />);
await waitFor(() => expect(screen.getByTestId("voice").textContent).toContain('"supported":true'));
fireEvent.click(screen.getByText("start"));
await waitFor(() => expect(port.onmessage).toBeTypeOf("function"));
view.rerender(<Harness projectId="project-b" />);
await waitFor(() => expect(vi.mocked(fetch).mock.calls.some(([url, init]) => url === "/api/voice/session/session-a?projectId=project-a" && init?.method === "DELETE")).toBe(true));
expect(tracks[0].stop).toHaveBeenCalledOnce();
expect(screen.getByTestId("voice").textContent).not.toContain('"partialText":"partial"');
});
it("serializes buffered worklet frames and sends a bounded finalization", async () => {
const { tracks, port } = installAudioCapture();
availableResponses();

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { withProjectId } from "../api/health.js";
import { useVoiceAvailability } from "./useVoiceAvailability";
export type VoiceDictationState = "idle" | "listening" | "transcribing" | "error";
@@ -26,6 +27,7 @@ export function useVoiceDictation(projectId?: string) {
const sourceRef = useRef<MediaStreamAudioSourceNode | undefined>(undefined);
const workletUrlRef = useRef<string | undefined>(undefined);
const sessionRef = useRef<string | undefined>(undefined);
const sessionUrlsRef = useRef(new Map<string, { transcribe: string; delete: string }>());
// FNXC:VoiceInput 2026-07-25-20:30: Sequence numbers belong to backend sessions,
// not the hook instance. A new capture may start while an old stopped session finalizes.
const sequenceRef = useRef(new Map<string, number>());
@@ -73,12 +75,18 @@ export function useVoiceDictation(projectId?: string) {
sessionRef.current = undefined;
if (id) {
sequenceRef.current.delete(id);
void fetch(`/api/voice/session/${encodeURIComponent(id)}`, { method: "DELETE" }).catch(() => undefined);
const urls = sessionUrlsRef.current.get(id);
sessionUrlsRef.current.delete(id);
void fetch(urls?.delete ?? `/api/voice/session/${encodeURIComponent(id)}`, { method: "DELETE" }).catch(() => undefined);
}
}
}, [releaseCapture]);
useEffect(() => release, [release]);
// FNXC:VoiceInput 2026-08-04-07:37:
// Status and every session operation must share the selected project scope. Changing projects
// invalidates the old capture generation and deletes it with its original scoped URL.
useEffect(() => { release(); }, [projectId, release]);
const fail = useCallback((message: string) => { release(); setError(message); setState("error"); }, [release]);
const sendChunk = useCallback(async (blob: Blob, final: boolean, sessionId: string, generation: number) => {
@@ -92,7 +100,9 @@ export function useVoiceDictation(projectId?: string) {
try {
const sequence = sequenceRef.current.get(sessionId) ?? 0;
sequenceRef.current.set(sessionId, sequence + 1);
response = await fetch("/api/voice/transcribe", { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await blob.arrayBuffer()), sequence, final, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
const urls = sessionUrlsRef.current.get(sessionId);
if (!urls) return;
response = await fetch(urls.transcribe, { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await blob.arrayBuffer()), sequence, final, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
} finally {
controllers.delete(controller);
if (controllers.size === 0) transcriptionControllersRef.current.delete(sessionId);
@@ -146,15 +156,19 @@ export function useVoiceDictation(projectId?: string) {
const generation = generationRef.current + 1;
generationRef.current = generation;
setError(undefined); setPartialText(""); setFinalText("");
const sessionUrl = withProjectId("/api/voice/session", projectId);
const transcribeUrl = withProjectId("/api/voice/transcribe", projectId);
try {
const session = await fetch("/api/voice/session", { method: "POST" });
const session = await fetch(sessionUrl, { method: "POST" });
if (!session.ok) throw new Error("Voice session unavailable");
const sessionId = (await session.json() as { sessionId: string }).sessionId;
const deleteUrl = withProjectId(`/api/voice/session/${encodeURIComponent(sessionId)}`, projectId);
if (!startInProgressRef.current || generationRef.current !== generation) {
void fetch(`/api/voice/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch(() => undefined);
void fetch(deleteUrl, { method: "DELETE" }).catch(() => undefined);
return;
}
sessionRef.current = sessionId;
sessionUrlsRef.current.set(sessionId, { transcribe: transcribeUrl, delete: deleteUrl });
sequenceRef.current.set(sessionId, 0);
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000 } });
if (!startInProgressRef.current || generationRef.current !== generation) {
@@ -185,9 +199,10 @@ export function useVoiceDictation(projectId?: string) {
} catch (reason) {
if (generationRef.current === generation) fail(reason instanceof Error ? reason.message : "Microphone permission was denied");
}
}, [enabled, fail, flushBuffers, supported]);
}, [enabled, fail, flushBuffers, projectId, supported]);
const stop = useCallback(() => {
const sessionId = sessionRef.current;
const deleteUrl = sessionId ? sessionUrlsRef.current.get(sessionId)?.delete : undefined;
const generation = generationRef.current;
if (!sessionId || stoppingRef.current) { releaseCapture(); setState("idle"); return; }
stoppingRef.current = true;
@@ -225,7 +240,9 @@ export function useVoiceDictation(projectId?: string) {
try {
const sequence = sequenceRef.current.get(sessionId) ?? 0;
sequenceRef.current.set(sessionId, sequence + 1);
const response = await fetch("/api/voice/transcribe", { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await trailing.arrayBuffer()), sequence, final: true, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
const urls = sessionUrlsRef.current.get(sessionId);
if (!urls) return;
const response = await fetch(urls.transcribe, { signal: controller.signal, method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId, audio: base64(await trailing.arrayBuffer()), sequence, final: true, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
if (!response.ok) throw new Error("Voice transcription finalization failed");
const result = await response.json() as { text?: string; partial?: string; final?: boolean };
if (generationRef.current === generation && result.final) {
@@ -240,9 +257,18 @@ export function useVoiceDictation(projectId?: string) {
} catch { /* capture teardown remains successful when transcription cannot finish */ }
finally {
stoppingRef.current = false;
if (sessionRef.current === sessionId) sessionRef.current = undefined;
sequenceRef.current.delete(sessionId);
void fetch(`/api/voice/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch(() => undefined);
/*
* FNXC:VoiceInput 2026-08-04-07:58:
* Stop may be awaiting finalization when unmount or a project change releases this session.
* Only the lifecycle generation that still owns the session may delete it, preventing the
* old stop continuation from issuing a duplicate cleanup request after teardown.
*/
if (sessionRef.current === sessionId && generationRef.current === generation) {
sessionRef.current = undefined;
sequenceRef.current.delete(sessionId);
sessionUrlsRef.current.delete(sessionId);
void fetch(deleteUrl ?? `/api/voice/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }).catch(() => undefined);
}
}
})();
}, [releaseCapture]);

View File

@@ -58,6 +58,34 @@ describe("voice route authorization split", () => {
});
});
it("keeps an ordered partial/final session in its owning project through cleanup", async () => {
const app = express(); const router = express.Router(); app.use(router);
const acceptChunk = vi.fn((_audio: Buffer, options: { final: boolean }) => options.final ? { text: "final transcript" } : { partial: "partial transcript" });
const close = vi.fn();
createRegisterVoiceRoutes({
manager: { getState: async () => ({ status: "installed" as const, installedPath: "/model" }), peekState: () => ({ status: "installed" as const, installedPath: "/model" }), scheduleDownload: () => ({ accepted: false as const, state: { status: "error" as const } }), remove: async () => {}, download: async () => ({ status: "installed" as const }), subscribe: () => () => {} },
service: { getRuntimeStatus: async () => ({ status: "available" as const }), createSession: async () => ({ acceptChunk, finish: () => ({ text: "unused" }), close }) },
})({ router, getScopedStore: async () => ({ getSettings: async () => ({ voiceInput: { enabled: true } }), getGlobalSettingsStore: () => ({ getSettings: async () => ({}) }) }), getProjectIdFromRequest: (request) => typeof request.query.projectId === "string" ? request.query.projectId : undefined } as unknown as ApiRoutesContext);
const server = app.listen(0); servers.push(server);
await new Promise<void>((resolve) => server.once("listening", resolve));
const port = (server.address() as AddressInfo).port;
const request = (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${port}${path}`, init);
const headers = { "content-type": "application/json" };
const projectA = "voice-project";
expect((await request(`/voice/status?projectId=${projectA}`)).status).toBe(200);
const { sessionId } = await (await request(`/voice/session?projectId=${projectA}`, { method: "POST" })).json() as { sessionId: string };
const partial = await request(`/voice/transcribe?projectId=${projectA}`, { method: "POST", headers, body: JSON.stringify({ sessionId, audio: "AAA=", sequence: 0, final: false, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
expect(await partial.json()).toMatchObject({ partial: "partial transcript", final: false });
const final = await request(`/voice/transcribe?projectId=${projectA}`, { method: "POST", headers, body: JSON.stringify({ sessionId, audio: "AAA=", sequence: 1, final: true, sampleRate: 16000, channels: 1, encoding: "pcm_s16le" }) });
expect(await final.json()).toMatchObject({ text: "final transcript", final: true });
const foreign = await request(`/voice/transcribe?projectId=other-project`, { method: "POST", headers, body: JSON.stringify({ sessionId, audio: "AAA=", sequence: 2, final: false }) });
expect(await foreign.json()).toEqual({ error: "unknown-session" });
expect(await (await request(`/voice/session/${sessionId}?projectId=${projectA}`, { method: "DELETE" })).json()).toMatchObject({ closed: true, alreadyClosed: true });
expect(acceptChunk).toHaveBeenNthCalledWith(1, Buffer.from([0, 0]), { final: false });
expect(acceptChunk).toHaveBeenNthCalledWith(2, Buffer.from([0, 0]), { final: true });
expect(close).toHaveBeenCalledOnce();
});
it("allows lifecycle inspection while dictation is disabled", async () => {
const request = await harness(false);
expect((await request("/voice/status")).status).toBe(200);