import { AudioFrame } from "@livekit/rtc-node";
import { ReadableStream } from "node:stream/web";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { APIStatusError, APITimeoutError } from "../../_exceptions.js";
import { ChatContext } from "../../llm/chat_context.js";
import { initializeLogger } from "../../log.js";
import { AudioRecognition } from "../../voice/audio_recognition.js";
import { MockWebSocket } from "./_mock_ws.js";
import { apiConnectDefaults } from "./defaults.js";
import { AdaptiveInterruptionDetector } from "./interruption_detector.js";
import { InterruptionStreamBase, InterruptionStreamSentinel } from "./interruption_stream.js";
vi.mock("ws", async () => {
  const { MockWebSocket: MockWebSocket2 } = await import("./_mock_ws.js");
  return { default: MockWebSocket2, WebSocket: MockWebSocket2 };
});
initializeLogger({ pretty: false, level: "silent" });
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForInstance(timeoutMs = 2e3) {
  const start = performance.now();
  while (MockWebSocket.instances.length === 0) {
    if (performance.now() - start > timeoutMs) {
      throw new Error("WebSocket instance was never constructed");
    }
    await sleep(5);
  }
  return MockWebSocket.instances[MockWebSocket.instances.length - 1];
}
async function waitForInstanceCount(n, timeoutMs = 2e3) {
  const start = performance.now();
  while (MockWebSocket.instances.length < n) {
    if (performance.now() - start > timeoutMs) {
      throw new Error(`expected ${n} WebSocket instances, saw ${MockWebSocket.instances.length}`);
    }
    await sleep(5);
  }
  return MockWebSocket.instances[n - 1];
}
async function waitFor(predicate, timeoutMs = 2e3) {
  const start = performance.now();
  while (!predicate()) {
    if (performance.now() - start > timeoutMs) {
      throw new Error("condition not met within timeout");
    }
    await sleep(5);
  }
}
function stillAlive(errPromise, ms = 30) {
  return Promise.race([errPromise, sleep(ms).then(() => "alive")]);
}
function sessionCreateSettings(ws) {
  return JSON.parse(String(ws.sent[0])).settings;
}
function makeAudioFrame(numSamples = 1600, sampleRate = 16e3) {
  const data = new Int16Array(numSamples);
  return new AudioFrame(data, sampleRate, 1, numSamples);
}
function createDetector(opts = {}) {
  return new AdaptiveInterruptionDetector({
    baseUrl: "http://localhost:9999",
    apiKey: "test-key",
    apiSecret: "test-secret",
    ...opts
  });
}
async function readError(stream) {
  const reader = stream.stream().getReader();
  try {
    for (; ; ) {
      const { done } = await reader.read();
      if (done) return void 0;
    }
  } catch (e) {
    return e;
  } finally {
    try {
      reader.releaseLock();
    } catch {
    }
  }
}
beforeEach(() => {
  MockWebSocket.instances.length = 0;
});
afterEach(() => {
  vi.clearAllMocks();
});
describe("interruption WebSocket transport failover", () => {
  it("surfaces a non-retryable APITimeoutError on connection timeout", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, { timeout: 50 });
    const err = await readError(stream);
    expect(err).toBeInstanceOf(APITimeoutError);
    expect(err.retryable).toBe(false);
    await stream.close();
  });
  it("surfaces a non-retryable APIStatusError on connection 429", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws = await waitForInstance();
    ws.simulateUnexpectedResponse(429);
    const err = await errPromise;
    expect(err).toBeInstanceOf(APIStatusError);
    expect(err.statusCode).toBe(429);
    expect(err.retryable).toBe(false);
    await stream.close();
  });
  it("surfaces a non-retryable 408 APIStatusError when inference responses time out", async () => {
    const inferenceTimeout = 50;
    const detector = createDetector({ inferenceTimeout });
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws = await waitForInstance();
    ws.simulateOpen();
    await sleep(5);
    await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted());
    await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now()));
    await stream.pushFrame(makeAudioFrame());
    await sleep(inferenceTimeout + 40);
    await stream.pushFrame(makeAudioFrame());
    const err = await errPromise;
    expect(err).toBeInstanceOf(APIStatusError);
    expect(err.statusCode).toBe(408);
    expect(err.retryable).toBe(false);
    await stream.close();
  });
  it("errors when session.created reports no threshold and the user did not override", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws = await waitForInstance();
    ws.simulateOpen();
    await sleep(5);
    ws.simulateMessage({ type: "session.created" });
    const err = await errPromise;
    expect(err).toBeInstanceOf(APIStatusError);
    expect(err.statusCode).toBe(500);
    expect(err.retryable).toBe(false);
    await stream.close();
  });
  it("does not error when session.created carries a default_threshold", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws = await waitForInstance();
    ws.simulateOpen();
    await sleep(5);
    ws.simulateMessage({ type: "session.created", default_threshold: 0.42 });
    await sleep(5);
    await stream.close();
    expect(await errPromise).toBeUndefined();
  });
  it("closes the underlying WebSocket on teardown even after the transport errored", async () => {
    const inferenceTimeout = 50;
    const detector = createDetector({ inferenceTimeout });
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws = await waitForInstance();
    ws.simulateOpen();
    await sleep(5);
    await stream.pushFrame(InterruptionStreamSentinel.agentSpeechStarted());
    await stream.pushFrame(InterruptionStreamSentinel.overlapSpeechStarted(500, Date.now()));
    await stream.pushFrame(makeAudioFrame());
    await sleep(inferenceTimeout + 40);
    await stream.pushFrame(makeAudioFrame());
    await errPromise;
    expect(ws.readyState).toBe(MockWebSocket.OPEN);
    await stream.close();
    expect(ws.readyState).toBe(3);
  });
});
describe("interruption updateOptions reconnect", () => {
  it("reconnects in place with the updated threshold without erroring the stream", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws1 = await waitForInstance();
    ws1.simulateOpen();
    await waitFor(() => ws1.sent.length > 0);
    expect("threshold" in sessionCreateSettings(ws1)).toBe(false);
    await stream.updateOptions({ threshold: 0.7 });
    const ws2 = await waitForInstanceCount(2);
    expect(ws2).not.toBe(ws1);
    expect(ws1.readyState).toBe(3);
    ws2.simulateOpen();
    await waitFor(() => ws2.sent.length > 0);
    expect(sessionCreateSettings(ws2).threshold).toBe(0.7);
    await expect(stillAlive(errPromise)).resolves.toBe("alive");
    await stream.close();
  });
  it("does not error the stream across more reconnects than the failover budget", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws1 = await waitForInstance();
    ws1.simulateOpen();
    await waitFor(() => ws1.sent.length > 0);
    const rounds = apiConnectDefaults.maxRetries + 2;
    for (let i = 0; i < rounds; i++) {
      await stream.updateOptions({ threshold: 0.5 + (i + 1) * 0.01 });
      const ws = await waitForInstanceCount(i + 2);
      ws.simulateOpen();
      await waitFor(() => ws.sent.length > 0);
    }
    expect(MockWebSocket.instances.length).toBe(rounds + 1);
    await expect(stillAlive(errPromise)).resolves.toBe("alive");
    await stream.close();
  });
  it("errors the stream when the post-updateOptions reconnect genuinely fails", async () => {
    const detector = createDetector();
    const stream = new InterruptionStreamBase(detector, {});
    const errPromise = readError(stream);
    const ws1 = await waitForInstance();
    ws1.simulateOpen();
    await waitFor(() => ws1.sent.length > 0);
    await stream.updateOptions({ threshold: 0.7 });
    const ws2 = await waitForInstanceCount(2);
    ws2.simulateUnexpectedResponse(429);
    const err = await errPromise;
    expect(err).toBeInstanceOf(APIStatusError);
    expect(err.statusCode).toBe(429);
    await stream.close();
  });
});
function createHooks() {
  return {
    onInterruption: vi.fn(),
    onStartOfSpeech: vi.fn(),
    onVADInferenceDone: vi.fn(),
    onEndOfSpeech: vi.fn(),
    onInterimTranscript: vi.fn(),
    onFinalTranscript: vi.fn(),
    onPreemptiveGeneration: vi.fn(),
    onAgentBackchannelOpportunity: vi.fn(),
    retrieveChatCtx: () => ChatContext.empty(),
    onEndOfTurn: vi.fn(async () => true)
  };
}
describe("interruption failover error emission", () => {
  it("emits exactly one unrecoverable error for a non-retryable transport failure", async () => {
    const transportError = new APIStatusError({
      message: "WebSocket connection rejected with status 429",
      options: { statusCode: 429, retryable: false }
    });
    expect(transportError.retryable).toBe(false);
    const errors = [];
    const erroringStream = {
      stream: () => new ReadableStream({
        start(controller) {
          controller.error(transportError);
        }
      }),
      pushFrame: async () => {
      },
      close: async () => {
      }
    };
    const mockDetector = {
      label: "mock-detector",
      createStream: () => erroringStream,
      emitError: (e) => errors.push(e)
    };
    const recognition = new AudioRecognition({
      recognitionHooks: createHooks(),
      interruptionDetection: mockDetector
    });
    const ac = new AbortController();
    const task = recognition.createInterruptionTask(mockDetector, ac.signal);
    const start = performance.now();
    while (errors.length === 0 && performance.now() - start < 2e3) {
      await sleep(5);
    }
    ac.abort();
    await task;
    expect(errors.filter((e) => e.recoverable)).toHaveLength(0);
    expect(errors.filter((e) => !e.recoverable)).toHaveLength(1);
  });
});
//# sourceMappingURL=interruption_failover.test.js.map