import { describe, expect, mock, spyOn, test } from "bun:test"; import type { VoiceTurnCallbacks, VoiceTurnOptions, } from "../../calls/voice-session-bridge.js"; import { loadRawConfig, saveRawConfig } from "../../config/loader.js"; import type { StreamingTranscriber, SttStreamServerEvent, } from "../../stt/types.js"; import type { LiveVoiceAudioArchiveResult } from "../live-voice-archive.js"; import * as liveVoiceArchive from "../live-voice-archive.js"; import { createLiveVoiceSession, type LiveVoiceSessionArchiveAudioInput, type LiveVoiceTtsStreamer, type LiveVoiceTurnStarter, } from "../live-voice-session.js"; import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js"; import type { LiveVoiceTtsAudioChunk, LiveVoiceTtsOptions, LiveVoiceTtsResult, } from "../live-voice-tts.js"; import { createLiveVoiceServerFrameSequencer, type LiveVoiceClientStartFrame, type LiveVoiceServerFrame, } from "../protocol.js"; const START_FRAME = { type: "start", conversationId: "conversation-123", audio: { mimeType: "audio/pcm", sampleRate: 24_000, channels: 1, }, } as const satisfies LiveVoiceClientStartFrame; // Multi-turn sessions are a server_vad capability; the multi-cycle tests // drive utterance boundaries via the ptt_release manual override. const VAD_START_FRAME = { ...START_FRAME, turnDetection: "server_vad", } as const satisfies LiveVoiceClientStartFrame; function loudPcmChunk(amplitude: number, sampleCount = 240): Uint8Array { const buffer = Buffer.alloc(sampleCount * 2); for (let index = 0; index < sampleCount; index += 1) { buffer.writeInt16LE(amplitude, index * 2); } return new Uint8Array(buffer); } class FakeStreamingTranscriber implements StreamingTranscriber { readonly providerId = "deepgram" as const; readonly boundaryId = "daemon-streaming" as const; readonly audioChunks: Buffer[] = []; stopped = false; private onEvent: ((event: SttStreamServerEvent) => void) | null = null; constructor(private readonly transcript = "hello from live voice") {} async start(onEvent: (event: SttStreamServerEvent) => void): Promise { this.onEvent = onEvent; } sendAudio(audio: Buffer): void { this.audioChunks.push(Buffer.from(audio)); this.emit({ type: "partial", text: "hel" }); } stop(): void { this.stopped = true; this.emit({ type: "final", text: this.transcript }); this.emit({ type: "closed" }); } private emit(event: SttStreamServerEvent): void { this.onEvent?.(event); } } // Finalize-capable fake: with server_vad the session keeps this single // stream for every cycle (persistent mode). Each finalize flushes // "utterance " as a final followed by finalized; stop() remains the // session-teardown path. class PersistentFakeStreamingTranscriber implements StreamingTranscriber { readonly providerId = "deepgram" as const; readonly boundaryId = "daemon-streaming" as const; readonly audioChunks: Buffer[] = []; startCalls = 0; stopCalls = 0; finalizeCalls = 0; private onEvent: ((event: SttStreamServerEvent) => void) | null = null; async start(onEvent: (event: SttStreamServerEvent) => void): Promise { this.startCalls += 1; this.onEvent = onEvent; } sendAudio(audio: Buffer): void { this.audioChunks.push(Buffer.from(audio)); this.onEvent?.({ type: "partial", text: "hel" }); } finalizeUtterance(): void { this.finalizeCalls += 1; this.onEvent?.({ type: "final", text: `utterance ${this.finalizeCalls}` }); this.onEvent?.({ type: "finalized" }); } stop(): void { this.stopCalls += 1; this.onEvent?.({ type: "closed" }); } } function createContext(startFrame: LiveVoiceClientStartFrame = START_FRAME): { context: LiveVoiceSessionFactoryContext; frames: LiveVoiceServerFrame[]; } { const sequencer = createLiveVoiceServerFrameSequencer(); const frames: LiveVoiceServerFrame[] = []; return { frames, context: { sessionId: "session-123", startFrame, sendFrame: mock(async (payload) => { const frame = sequencer.next(payload); frames.push(frame); return frame; }), }, }; } function createClock(): () => number { let now = 1_000; return () => { now += 25; return now; }; } function makeArchiveResult( input: LiveVoiceSessionArchiveAudioInput, ): LiveVoiceAudioArchiveResult { const attachmentId = `${input.role}-attachment-123`; return { type: "archived", artifact: { source: "live-voice", archiveKey: `live-voice:${input.sessionId}:${input.turnId}:${input.role}`, attachmentId, sessionId: input.sessionId, turnId: input.turnId, role: input.role, mimeType: input.mimeType, ...(input.sampleRate !== undefined ? { sampleRate: input.sampleRate } : {}), ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}), sizeBytes: Buffer.byteLength(input.audio.dataBase64, "base64"), filename: `${attachmentId}.pcm`, archivedAt: 1_234, }, idempotent: false, }; } function makeTtsChunk(text: string): LiveVoiceTtsAudioChunk { return { type: "tts_audio", contentType: "audio/pcm", sampleRate: 24_000, dataBase64: Buffer.from(text).toString("base64"), }; } function makeTtsResult(text: string): LiveVoiceTtsResult { return { provider: "fish-audio", contentType: "audio/pcm", sampleRate: 24_000, chunks: 1, bytes: Buffer.byteLength(text), }; } function makeTextDelta( text: string, ): Parameters>[0] { return { type: "assistant_text_delta", text, conversationId: "conversation-123", }; } function makeMessageComplete(): Parameters< NonNullable >[0] { return { type: "message_complete", conversationId: "conversation-123", messageId: "assistant-message-123", }; } async function waitFor( predicate: () => boolean, message = "Timed out waiting for live voice integration condition", ): Promise { for (let attempt = 0; attempt < 80; attempt += 1) { if (predicate()) { return; } await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(message); } function frameTypes(frames: LiveVoiceServerFrame[]): string[] { return frames.map((frame) => frame.type); } // Harness for multi-cycle tests: every resolve yields a fresh transcriber // whose transcript is "utterance ", and turn ids count up per cycle. function createMultiCycleHarness(startVoiceTurn: LiveVoiceTurnStarter) { const transcribers: FakeStreamingTranscriber[] = []; const resolveTranscriber = mock(async () => { const transcriber = new FakeStreamingTranscriber( `utterance ${transcribers.length + 1}`, ); transcribers.push(transcriber); return transcriber; }); const archiveAudio = mock(async (input: LiveVoiceSessionArchiveAudioInput) => makeArchiveResult(input), ); const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => { options.onAudioChunk(makeTtsChunk(`audio:${options.text}`)); return makeTtsResult(options.text); }); const { context, frames } = createContext(VAD_START_FRAME); let turnCount = 0; const session = createLiveVoiceSession(context, { // Credential-free harness: every leg is injected, so skip the preflight. resolveCredentialReadiness: null, // These cycle mechanics use one discrete mic chunk per utterance. Keep // the adaptive playback classifier out of their timing model. echoBargeInMargin: 1, resolveTranscriber, startVoiceTurn, streamTtsAudio, archiveAudio, metricsClock: createClock(), createTurnId: () => { turnCount += 1; return `live-turn-${turnCount}`; }, }); return { archiveAudio, frames, session, transcribers }; } describe("LiveVoiceSession integration smoke harness", () => { test("runs a full credential-free live voice turn through STT, bridge, TTS, archive, and metrics", async () => { const transcriber = new FakeStreamingTranscriber(); const archiveAudio = mock( async (input: LiveVoiceSessionArchiveAudioInput) => makeArchiveResult(input), ); const startVoiceTurn = mock(async (options: VoiceTurnOptions) => { options.callbacks?.persisted_user_message_id?.("user-message-123"); options.callbacks?.assistant_text_delta?.( makeTextDelta("Hello from the assistant."), ); options.callbacks?.message_complete?.(makeMessageComplete()); return { turnId: "bridge-turn-1", abort: mock() }; }); const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => { options.onAudioChunk(makeTtsChunk(`audio:${options.text}`)); return makeTtsResult(options.text); }); const { context, frames } = createContext(); const session = createLiveVoiceSession(context, { resolveCredentialReadiness: null, resolveTranscriber: mock(async () => transcriber), startVoiceTurn, streamTtsAudio, archiveAudio, metricsClock: createClock(), createTurnId: () => "live-turn-1", }); await session.start(); await session.handleBinaryAudio(new Uint8Array([1, 2, 3, 4])); await session.handleClientFrame({ type: "ptt_release" }); await waitFor(() => frames.some((frame) => frame.type === "tts_done")); expect(transcriber.audioChunks).toHaveLength(1); expect(transcriber.audioChunks[0]).toEqual(Buffer.from([1, 2, 3, 4])); expect(transcriber.stopped).toBe(true); expect(startVoiceTurn).toHaveBeenCalledTimes(1); expect(startVoiceTurn.mock.calls[0]?.[0]).toMatchObject({ conversationId: "conversation-123", voiceSessionId: "session-123", userMessageChannel: "vellum", assistantMessageChannel: "vellum", userMessageInterface: "macos", assistantMessageInterface: "macos", content: "hello from live voice", isInbound: true, }); expect(streamTtsAudio).toHaveBeenCalledTimes(1); expect(streamTtsAudio.mock.calls[0]?.[0]).toMatchObject({ text: "Hello from the assistant.", outputFormat: "pcm", sampleRate: 24_000, }); expect(archiveAudio.mock.calls.map((call) => call[0].role)).toEqual([ "user", "assistant", ]); expect(frameTypes(frames)).toEqual([ "ready", "stt_partial", "stt_final", "thinking", "assistant_text_delta", "tts_audio", "archived", "archived", "metrics", "tts_done", ]); expect(frames[5]).toMatchObject({ type: "tts_audio", dataBase64: Buffer.from("audio:Hello from the assistant.").toString( "base64", ), }); expect(frames[6]).toMatchObject({ type: "archived", role: "user", attachmentIds: ["user-attachment-123"], }); expect(frames[7]).toMatchObject({ type: "archived", role: "assistant", attachmentIds: ["assistant-attachment-123"], }); expect(frames[8]).toMatchObject({ type: "metrics", event: "turn_completed", sessionId: "session-123", conversationId: "conversation-123", turnId: "live-turn-1", metrics: { summary: { completedTurnCount: 1, cancelledTurnCount: 0, }, }, }); expect(frames[9]).toMatchObject({ type: "tts_done", turnId: "live-turn-1", }); }); test("emits archive and cancelled metrics for an interrupted live voice turn", async () => { const transcriber = new FakeStreamingTranscriber(); const abort = mock(); const archiveAudio = mock( async (input: LiveVoiceSessionArchiveAudioInput) => makeArchiveResult(input), ); const startVoiceTurn: LiveVoiceTurnStarter = mock( async (options: VoiceTurnOptions) => { options.callbacks?.persisted_user_message_id?.("user-message-123"); return { turnId: "bridge-turn-1", abort }; }, ); const streamTtsAudio: LiveVoiceTtsStreamer = mock( async (options: LiveVoiceTtsOptions) => { options.onAudioChunk(makeTtsChunk("late audio")); return makeTtsResult("late audio"); }, ); const { context, frames } = createContext(); const session = createLiveVoiceSession(context, { resolveCredentialReadiness: null, resolveTranscriber: mock(async () => transcriber), startVoiceTurn, streamTtsAudio, archiveAudio, metricsClock: createClock(), createTurnId: () => "live-turn-1", }); await session.start(); await session.handleBinaryAudio(new Uint8Array([9, 8, 7, 6])); await session.handleClientFrame({ type: "ptt_release" }); await waitFor(() => frames.some((frame) => frame.type === "thinking")); await session.handleClientFrame({ type: "interrupt" }); await waitFor(() => frames.some( (frame) => frame.type === "metrics" && frame.event === "turn_cancelled", ), ); expect(abort).toHaveBeenCalledTimes(1); expect(streamTtsAudio).not.toHaveBeenCalled(); expect(archiveAudio).toHaveBeenCalledTimes(1); expect(archiveAudio.mock.calls[0]?.[0]).toMatchObject({ role: "user", messageId: "user-message-123", sessionId: "session-123", turnId: "live-turn-1", }); expect(frameTypes(frames)).toEqual([ "ready", "stt_partial", "stt_final", "thinking", "archived", "metrics", ]); expect(frames[4]).toMatchObject({ type: "archived", role: "user", attachmentIds: ["user-attachment-123"], }); expect(frames[5]).toMatchObject({ type: "metrics", event: "turn_cancelled", turnId: "live-turn-1", metrics: { summary: { completedTurnCount: 0, cancelledTurnCount: 1, }, }, }); }); test("runs two full utterance cycles on one server_vad session with isolated per-turn archives", async () => { const startVoiceTurn = mock(async (options: VoiceTurnOptions) => { options.callbacks?.persisted_user_message_id?.("user-message-123"); options.callbacks?.assistant_text_delta?.( makeTextDelta(`Reply to ${options.content}.`), ); options.callbacks?.message_complete?.(makeMessageComplete()); return { turnId: "bridge-turn-1", abort: mock() }; }); const { archiveAudio, frames, session, transcribers } = createMultiCycleHarness(startVoiceTurn); const firstUtteranceAudio = loudPcmChunk(8_000); const secondUtteranceAudio = loudPcmChunk(9_000); await session.start(); await session.handleBinaryAudio(firstUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor( () => frames.filter((frame) => frame.type === "tts_done").length === 1, ); await session.handleBinaryAudio(secondUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor( () => frames.filter((frame) => frame.type === "tts_done").length === 2, ); expect(startVoiceTurn.mock.calls.map((call) => call[0].content)).toEqual([ "utterance 1", "utterance 2", ]); expect( frames.flatMap((frame) => frame.type === "stt_final" ? [frame.text] : [], ), ).toEqual(["utterance 1", "utterance 2"]); expect( frames.flatMap((frame) => frame.type === "tts_done" ? [frame.turnId] : [], ), ).toEqual(["live-turn-1", "live-turn-2"]); expect( archiveAudio.mock.calls.map((call) => [call[0].role, call[0].turnId]), ).toEqual([ ["user", "live-turn-1"], ["assistant", "live-turn-1"], ["user", "live-turn-2"], ["assistant", "live-turn-2"], ]); const archivedUserAudio = archiveAudio.mock.calls .filter((call) => call[0].role === "user") .map((call) => Buffer.from(call[0].audio.dataBase64, "base64")); expect(archivedUserAudio).toEqual([ Buffer.from(firstUtteranceAudio), Buffer.from(secondUtteranceAudio), ]); const archivedAssistantAudio = archiveAudio.mock.calls .filter((call) => call[0].role === "assistant") .map((call) => Buffer.from(call[0].audio.dataBase64, "base64").toString(), ); expect(archivedAssistantAudio).toEqual([ "audio:Reply to utterance 1.", "audio:Reply to utterance 2.", ]); expect(transcribers).toHaveLength(3); expect(transcribers[0]?.stopped).toBe(true); expect(transcribers[1]?.stopped).toBe(true); expect(transcribers[2]?.stopped).toBe(false); }); test("runs two utterance cycles over one persistent transcriber without teardown between turns", async () => { const startVoiceTurn = mock(async (options: VoiceTurnOptions) => { options.callbacks?.persisted_user_message_id?.("user-message-123"); options.callbacks?.assistant_text_delta?.( makeTextDelta(`Reply to ${options.content}.`), ); options.callbacks?.message_complete?.(makeMessageComplete()); return { turnId: "bridge-turn-1", abort: mock() }; }); const transcriber = new PersistentFakeStreamingTranscriber(); const resolveTranscriber = mock(async () => transcriber); const archiveAudio = mock( async (input: LiveVoiceSessionArchiveAudioInput) => makeArchiveResult(input), ); const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => { options.onAudioChunk(makeTtsChunk(`audio:${options.text}`)); return makeTtsResult(options.text); }); const { context, frames } = createContext(VAD_START_FRAME); let turnCount = 0; const session = createLiveVoiceSession(context, { resolveCredentialReadiness: null, // This cycle mechanic uses one discrete mic chunk per utterance. echoBargeInMargin: 1, resolveTranscriber, startVoiceTurn, streamTtsAudio, archiveAudio, metricsClock: createClock(), createTurnId: () => { turnCount += 1; return `live-turn-${turnCount}`; }, }); const firstUtteranceAudio = loudPcmChunk(8_000); const secondUtteranceAudio = loudPcmChunk(9_000); await session.start(); await session.handleBinaryAudio(firstUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor( () => frames.filter((frame) => frame.type === "tts_done").length === 1, ); await session.handleBinaryAudio(secondUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor( () => frames.filter((frame) => frame.type === "tts_done").length === 2, ); expect(resolveTranscriber).toHaveBeenCalledTimes(1); expect(transcriber.startCalls).toBe(1); expect(transcriber.stopCalls).toBe(0); expect(transcriber.finalizeCalls).toBe(2); expect(startVoiceTurn.mock.calls.map((call) => call[0].content)).toEqual([ "utterance 1", "utterance 2", ]); expect(transcriber.audioChunks).toEqual([ Buffer.from(firstUtteranceAudio), Buffer.from(secondUtteranceAudio), ]); expect( archiveAudio.mock.calls.map((call) => [call[0].role, call[0].turnId]), ).toEqual([ ["user", "live-turn-1"], ["assistant", "live-turn-1"], ["user", "live-turn-2"], ["assistant", "live-turn-2"], ]); // utteranceEnd → finalTranscript (the sttMs role in server_vad mode) // measures the finalize flush and still populates on every turn. const completedTurnMetrics = frames.flatMap((frame) => frame.type === "metrics" && frame.event === "turn_completed" ? [frame] : [], ); expect(completedTurnMetrics).toHaveLength(2); for (const frame of completedTurnMetrics) { expect(frame.sttMs).not.toBeNull(); } await session.close("websocket_close"); expect(transcriber.stopCalls).toBe(1); }); test("completes a full second cycle after an interrupt mid-turn", async () => { const abort = mock(); let voiceTurnCalls = 0; const startVoiceTurn = mock(async (options: VoiceTurnOptions) => { voiceTurnCalls += 1; if (voiceTurnCalls === 1) { options.callbacks?.persisted_user_message_id?.("user-message-123"); return { turnId: "bridge-turn-1", abort }; } options.callbacks?.assistant_text_delta?.(makeTextDelta("Second reply.")); options.callbacks?.message_complete?.(makeMessageComplete()); return { turnId: "bridge-turn-2", abort: mock() }; }); const { archiveAudio, frames, session } = createMultiCycleHarness(startVoiceTurn); const firstUtteranceAudio = loudPcmChunk(8_000); const secondUtteranceAudio = loudPcmChunk(9_000); await session.start(); await session.handleBinaryAudio(firstUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor(() => frames.some((frame) => frame.type === "thinking")); await session.handleClientFrame({ type: "interrupt" }); await waitFor(() => frames.some( (frame) => frame.type === "metrics" && frame.event === "turn_cancelled", ), ); expect(abort).toHaveBeenCalledTimes(1); await session.handleBinaryAudio(secondUtteranceAudio); await session.handleClientFrame({ type: "ptt_release" }); await waitFor(() => frames.some((frame) => frame.type === "tts_done")); expect(startVoiceTurn).toHaveBeenCalledTimes(2); expect(startVoiceTurn.mock.calls[1]?.[0]).toMatchObject({ content: "utterance 2", }); expect(frames.at(-1)).toMatchObject({ type: "tts_done", turnId: "live-turn-2", }); expect( frames.find( (frame) => frame.type === "metrics" && frame.event === "turn_completed", ), ).toMatchObject({ type: "metrics", event: "turn_completed", turnId: "live-turn-2", }); expect( archiveAudio.mock.calls.map((call) => [call[0].role, call[0].turnId]), ).toEqual([ ["user", "live-turn-1"], ["user", "live-turn-2"], ["assistant", "live-turn-2"], ]); const archivedUserAudio = archiveAudio.mock.calls .filter((call) => call[0].role === "user") .map((call) => Buffer.from(call[0].audio.dataBase64, "base64")); expect(archivedUserAudio).toEqual([ Buffer.from(firstUtteranceAudio), Buffer.from(secondUtteranceAudio), ]); }); }); // --------------------------------------------------------------------------- // Audio archiving is config-gated and OFF by default (JARVIS-1283): a voice // turn persists only its transcribed text, so no audio-file artifact lands on // the conversation messages unless `liveVoice.archiveAudio` is enabled. These // exercise the production factory's config resolution (no injected archiver). // --------------------------------------------------------------------------- describe("live-voice audio archiving default (JARVIS-1283)", () => { function makeSingleTurnLegs() { const startVoiceTurn = mock(async (options: VoiceTurnOptions) => { options.callbacks?.persisted_user_message_id?.("user-message-123"); options.callbacks?.assistant_text_delta?.( makeTextDelta("Hello from the assistant."), ); options.callbacks?.message_complete?.(makeMessageComplete()); return { turnId: "bridge-turn-1", abort: mock() }; }); const streamTtsAudio = mock(async (options: LiveVoiceTtsOptions) => { options.onAudioChunk(makeTtsChunk(`audio:${options.text}`)); return makeTtsResult(options.text); }); return { startVoiceTurn, streamTtsAudio }; } async function driveOneTurn( session: ReturnType, frames: LiveVoiceServerFrame[], ) { await session.start(); await session.handleBinaryAudio(new Uint8Array([1, 2, 3, 4])); await session.handleClientFrame({ type: "ptt_release" }); await waitFor(() => frames.some((frame) => frame.type === "tts_done")); } test("with no injected archiver and default config, nothing is archived", async () => { const { startVoiceTurn, streamTtsAudio } = makeSingleTurnLegs(); const { context, frames } = createContext(); // No `archiveAudio` option → the factory consults config, which defaults // off in the test workspace. const session = createLiveVoiceSession(context, { resolveCredentialReadiness: null, resolveTranscriber: mock(async () => new FakeStreamingTranscriber()), startVoiceTurn, streamTtsAudio, metricsClock: createClock(), createTurnId: () => "live-turn-1", }); await driveOneTurn(session, frames); // The turn completed, but no `archived` frame was emitted — no audio // attachment was written to either message. expect(startVoiceTurn).toHaveBeenCalledTimes(1); expect(frames.some((frame) => frame.type === "archived")).toBe(false); expect(frameTypes(frames)).not.toContain("archived"); }); test("liveVoice.archiveAudio=true wires the default archiver through the factory", async () => { // The real linkers write to the DB; these are role-less inputs, so stub // them with a valid archived result per role. The point under test is the // config→default-archiver wiring, not the DB write, which the // injected-archiver tests above already cover. const audioInput = { sessionId: "session-123", turnId: "live-turn-1", mimeType: "audio/pcm", audio: { type: "base64" as const, dataBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), }, }; const userSpy = spyOn( liveVoiceArchive, "linkLiveVoiceUserUtteranceAudioToMessage", ).mockResolvedValue(makeArchiveResult({ ...audioInput, role: "user" })); const assistantSpy = spyOn( liveVoiceArchive, "linkLiveVoiceAssistantResponseAudioToMessage", ).mockResolvedValue( makeArchiveResult({ ...audioInput, role: "assistant" }), ); const originalRaw = loadRawConfig(); saveRawConfig({ ...originalRaw, liveVoice: { archiveAudio: true } }); try { const { startVoiceTurn, streamTtsAudio } = makeSingleTurnLegs(); const { context, frames } = createContext(); // Still no injected `archiveAudio` — the config default must supply it. const session = createLiveVoiceSession(context, { resolveCredentialReadiness: null, resolveTranscriber: mock(async () => new FakeStreamingTranscriber()), startVoiceTurn, streamTtsAudio, metricsClock: createClock(), createTurnId: () => "live-turn-1", }); await driveOneTurn(session, frames); expect(userSpy).toHaveBeenCalledTimes(1); expect(assistantSpy).toHaveBeenCalledTimes(1); expect(frames.filter((frame) => frame.type === "archived")).toHaveLength( 2, ); } finally { saveRawConfig(originalRaw); userSpy.mockRestore(); assistantSpy.mockRestore(); } }); });