import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { NanobotClient } from "@/lib/nanobot-client";
import type { SidebarStatePayload } from "@/lib/types";

/**
 * Minimal fake WebSocket implementing the subset NanobotClient touches.
 * Every instance is retrievable via ``FakeSocket.instances`` so tests can
 * drive open/close/message lifecycles deterministically.
 */
class FakeSocket {
  static instances: FakeSocket[] = [];
  static readonly CONNECTING = 0;
  static readonly OPEN = 1;
  static readonly CLOSING = 2;
  static readonly CLOSED = 3;

  url: string;
  readyState = FakeSocket.CONNECTING;
  sent: string[] = [];
  onopen: (() => void) | null = null;
  onmessage: ((ev: MessageEvent) => void) | null = null;
  onerror: (() => void) | null = null;
  onclose: ((ev?: { code?: number }) => void) | null = null;

  constructor(url: string) {
    this.url = url;
    FakeSocket.instances.push(this);
  }

  send(data: string) {
    this.sent.push(data);
  }

  close() {
    this.readyState = FakeSocket.CLOSED;
    this.onclose?.();
  }

  /** Simulate a server-initiated drop with a specific wire-level close code
   * (e.g. ``1009`` for Message Too Big). */
  fakeCloseWithCode(code: number) {
    this.readyState = FakeSocket.CLOSED;
    this.onclose?.({ code });
  }

  fakeOpen() {
    this.readyState = FakeSocket.OPEN;
    this.onopen?.();
  }

  fakeMessage(payload: unknown) {
    this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
  }
}

function lastSocket(): FakeSocket {
  const s = FakeSocket.instances.at(-1);
  if (!s) throw new Error("no socket created yet");
  return s;
}

beforeEach(() => {
  FakeSocket.instances = [];
  vi.useFakeTimers();
});

afterEach(() => {
  Reflect.deleteProperty(window, "nanobotHost");
  vi.useRealTimers();
});

describe("NanobotClient", () => {
  it("reconciles simultaneous client submissions to the gateway-owned turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const socket = lastSocket();
    socket.fakeOpen();

    client.sendMessage("shared-chat", "from terminal B", undefined, {
      turnId: "turn-b",
    });
    expect(client.getRunTurnId("shared-chat")).toBe("turn-b");

    socket.fakeMessage({
      event: "message_accepted",
      chat_id: "shared-chat",
      turn_id: "turn-b",
      active_turn_id: "turn-a",
      starts_turn: false,
      started_at: 1_700_000_000,
    });

    expect(client.getRunTurnId("shared-chat")).toBe("turn-a");
    expect(client.getRunStartedAt("shared-chat")).toBe(1_700_000_000);
    expect(client.hasUnsettledRun("shared-chat")).toBe(true);
  });

  it("correlates successful WebUI mutation replies by request id", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const socket = lastSocket();
    socket.fakeOpen();

    const pending = client.requestMutation<{ saved: boolean }>(
      "settings.provider.update",
      { provider: "openrouter", apiKey: "secret" },
    );
    const frame = JSON.parse(socket.sent.at(-1) as string);
    expect(frame).toMatchObject({
      type: "webui_request",
      action: "settings.provider.update",
      payload: { provider: "openrouter", apiKey: "secret" },
    });
    expect(frame.request_id).toEqual(expect.any(String));

    socket.fakeMessage({
      event: "webui_response",
      request_id: frame.request_id,
      ok: true,
      result: { saved: true },
    });

    await expect(pending).resolves.toEqual({ saved: true });
  });

  it("surfaces correlated WebUI mutation errors with status", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const socket = lastSocket();
    socket.fakeOpen();

    const pending = client.requestMutation("settings.channel.configure", {});
    const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
    socket.fakeMessage({
      event: "webui_response",
      request_id: requestId,
      ok: false,
      error: { status: 400, message: "missing channel name" },
    });

    await expect(pending).rejects.toMatchObject({
      status: 400,
      message: "missing channel name",
    });
  });

  it("times out WebUI mutations without replaying them", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const socket = lastSocket();
    socket.fakeOpen();

    const pending = expect(
      client.requestMutation("skill.install", { skill: "docs" }, 25),
    ).rejects.toMatchObject({
      status: 504,
      message: "WebUI request timed out after 25ms",
    });
    expect(socket.sent).toHaveLength(1);
    await vi.advanceTimersByTimeAsync(25);

    await pending;
    expect(socket.sent).toHaveLength(1);
  });

  it("rejects in-flight WebUI mutations when the socket closes", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const socket = lastSocket();
    socket.fakeOpen();

    const pending = client.requestMutation("session.delete", {
      key: "websocket:chat-1",
    });
    socket.fakeCloseWithCode(1006);

    await expect(pending).rejects.toMatchObject({
      status: 503,
      message: "Socket closed before WebUI response",
    });
  });

  it("replays the exact serialized mutation frame after reconnect", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 10,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const firstSocket = lastSocket();
    firstSocket.fakeOpen();

    const payload = { id: "daily-summary", options: { force: false } };
    const pending = client.requestMutation<{ ran: boolean }>("automation.run", payload);
    const frame = firstSocket.sent.at(-1) as string;
    const requestId = JSON.parse(frame).request_id;
    payload.id = "weekly-summary";
    payload.options.force = true;
    const settled = expect(pending).resolves.toEqual({ ran: true });
    firstSocket.fakeCloseWithCode(1006);

    await vi.advanceTimersByTimeAsync(20);
    const retrySocket = lastSocket();
    retrySocket.fakeOpen();
    expect(retrySocket.sent).toEqual([frame]);
    retrySocket.fakeMessage({
      event: "webui_response",
      request_id: requestId,
      ok: true,
      result: { ran: true },
    });

    await settled;
  });

  it("does not retry a WebUI mutation after its timeout expires", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 1_000,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    const firstSocket = lastSocket();
    firstSocket.fakeOpen();

    const pending = expect(
      client.requestMutation("skill.install", { skill: "docs" }, 25),
    ).rejects.toMatchObject({ status: 504 });
    firstSocket.fakeCloseWithCode(1006);
    await vi.advanceTimersByTimeAsync(25);
    await pending;

    await vi.advanceTimersByTimeAsync(1_000);
    const retrySocket = lastSocket();
    retrySocket.fakeOpen();
    expect(retrySocket.sent).toEqual([]);
  });

  it("does not queue WebUI mutations before the authenticated socket opens", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();

    await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
      status: 503,
      message: "WebUI connection is not open",
    });
    expect(lastSocket().sent).toEqual([]);
  });

  it("keeps temporary chats out of attachment and reconnect state", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatId = "temp-server-id";
    client.connect();
    lastSocket().fakeOpen();
    const creation = client.newTemporaryChat();
    expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
      type: "new_temporary_chat",
    });
    lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true });
    await expect(creation).resolves.toBe(chatId);
    lastSocket().sent = [];
    client.onChat(chatId, vi.fn());
    client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });

    expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
      {
        type: "message",
        chat_id: chatId,
        content: "hello",
        turn_id: "turn-1",
        webui: true,
      },
    ]);

    client.discardTemporaryChat(chatId);
    expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
      type: "discard_temporary_chat",
      chat_id: chatId,
    });
  });

  it("waits for the temporary attachment when creating a temporary chat", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    const creation = client.newTemporaryChat();
    let resolved = false;
    void creation.then(() => { resolved = true; });
    lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" });
    await Promise.resolve();
    expect(resolved).toBe(false);

    lastSocket().fakeMessage({
      event: "attached",
      chat_id: "server-temporary-chat",
      temporary: true,
    });
    await expect(creation).resolves.toBe("server-temporary-chat");
  });

  it("forgets every temporary chat when the socket drops", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 1,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const firstHandler = vi.fn();
    const secondHandler = vi.fn();
    client.connect();
    lastSocket().fakeOpen();
    const firstCreation = client.newTemporaryChat();
    lastSocket().fakeMessage({
      event: "attached",
      chat_id: "temp-drop-a",
      temporary: true,
    });
    await firstCreation;
    const secondCreation = client.newTemporaryChat();
    lastSocket().fakeMessage({
      event: "attached",
      chat_id: "temp-drop-b",
      temporary: true,
    });
    await secondCreation;
    lastSocket().sent = [];
    client.onChat("temp-drop-a", firstHandler);
    client.onChat("temp-drop-b", secondHandler);
    firstHandler.mockClear();
    secondHandler.mockClear();
    lastSocket().close();

    await vi.advanceTimersByTimeAsync(1);
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "message",
      chat_id: "temp-drop-a",
      text: "stale first chat",
    });
    lastSocket().fakeMessage({
      event: "message",
      chat_id: "temp-drop-b",
      text: "stale second chat",
    });

    expect(lastSocket().sent).toEqual([]);
    expect(firstHandler).not.toHaveBeenCalled();
    expect(secondHandler).not.toHaveBeenCalled();
  });

  it("routes events to the matching chat handler", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onChat("chat-a", handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({ event: "message", chat_id: "chat-a", text: "hi" });
    lastSocket().fakeMessage({ event: "message", chat_id: "chat-b", text: "no" });
    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler.mock.calls[0][0]).toMatchObject({
      event: "message",
      chat_id: "chat-a",
      text: "hi",
    });
  });

  it("routes message acceptance acknowledgements to the matching chat handler", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onChat("chat-ack", handler);
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-ack", "hello", undefined, { turnId: "turn-ack" });

    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-ack",
      turn_id: "turn-ack",
    });

    expect(handler).toHaveBeenCalledWith({
      event: "message_accepted",
      chat_id: "chat-ack",
      turn_id: "turn-ack",
    });
  });

  it("can swap the socket factory when the runtime URL changes", () => {
    const browserFactory = vi.fn(
      (url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket,
    );
    const hostFactory = vi.fn(
      (url: string) => new FakeSocket(`host:${url}`) as unknown as WebSocket,
    );
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: browserFactory,
    });

    client.connect();
    expect(lastSocket().url).toBe("browser:ws://test");
    client.close();
    client.updateUrl("nanobot-host://engine/", hostFactory);
    client.connect();

    expect(hostFactory).toHaveBeenCalledWith("nanobot-host://engine/");
    expect(lastSocket().url).toBe("host:nanobot-host://engine/");
  });

  it("uses the host socket bridge for native host URLs", async () => {
    let socketEventHandler:
      | ((event: { id: string; type: "open" | "close" | "error"; message?: string }) => void)
      | null = null;
    const openSocket = vi.fn(async () => "host-socket-1");
    Object.defineProperty(window, "nanobotHost", {
      configurable: true,
      value: {
        openSocket,
        sendSocket: vi.fn(async () => undefined),
        closeSocket: vi.fn(async () => undefined),
        onSocketEvent: vi.fn((handler) => {
          socketEventHandler = handler;
          return vi.fn();
        }),
      },
    });
    const client = new NanobotClient({
      url: "nanobot-host://engine/",
      reconnect: false,
    });
    const status = vi.fn();
    client.onStatus(status);

    client.connect();
    await Promise.resolve();
    socketEventHandler?.({ id: "host-socket-1", type: "open" });

    expect(openSocket).toHaveBeenCalledWith("nanobot-host://engine/");
    expect(status).toHaveBeenLastCalledWith("open");
  });

  it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    // Nobody listening yet — deltas must not be dropped (user switched away).
    lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "a" });
    lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "b" });
    const handler = vi.fn();
    client.onChat("chat-queue", handler);
    expect(handler).toHaveBeenCalledTimes(2);
    expect(handler.mock.calls[0][0]).toMatchObject({ event: "delta", text: "a" });
    expect(handler.mock.calls[1][0]).toMatchObject({ event: "delta", text: "b" });
    lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "c" });
    expect(handler).toHaveBeenCalledTimes(3);
  });

  it("records goal_status run strip without an onChat subscriber", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-strip",
      status: "running",
      started_at: 12_345,
    });
    expect(client.getRunStartedAt("chat-strip")).toBe(12_345);
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-strip",
      status: "idle",
    });
    expect(client.getRunStartedAt("chat-strip")).toBeNull();
  });

  it("clears the local run strip immediately when a stop is requested", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onRunStatus(handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-stop",
      status: "running",
      started_at: 12_345,
      turn_id: "turn-stop",
    });

    client.finishRunLocally("chat-stop");

    expect(client.getRunStartedAt("chat-stop")).toBeNull();
    expect(client.hasUnsettledRun("chat-stop")).toBe(false);
    expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
  });

  it("clears stale run strip when reconnecting after a dropped socket", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 10,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onRunStatus(handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-strip",
      status: "running",
      started_at: 12_345,
    });

    lastSocket().close();

    expect(client.getRunStartedAt("chat-strip")).toBeNull();
    expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
    await vi.advanceTimersByTimeAsync(20);
    expect(FakeSocket.instances.length).toBeGreaterThan(1);
  });

  it("clears run strip when a turn_end arrives without idle", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onRunStatus(handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-strip",
      status: "running",
      started_at: 12_345,
    });
    lastSocket().fakeMessage({
      event: "turn_end",
      chat_id: "chat-strip",
    });
    expect(client.getRunStartedAt("chat-strip")).toBeNull();
    expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
  });

  it("rejects a completed snapshot when a newer run is not represented", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const requestGeneration = client.getRunGeneration("chat-race");

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-race",
      status: "running",
      started_at: 12_345,
      turn_id: "turn-new",
    });

    expect(
      client.reconcileCanonicalCompletion("chat-race", requestGeneration, ["turn-old"]),
    ).toBe(false);
    expect(client.getRunStartedAt("chat-race")).toBe(12_345);
  });

  it("rejects a user-only snapshot for a submitted turn that has not completed", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-submitted", "question", undefined, { turnId: "turn-submitted" });
    const requestGeneration = client.getRunGeneration("chat-submitted");

    expect(
      client.reconcileCanonicalCompletion("chat-submitted", requestGeneration, []),
    ).toBe(false);
  });

  it("does not register injected guidance as an independently unsettled run", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-guidance",
      status: "running",
      started_at: 12_345,
      turn_id: "turn-active",
    });
    const requestGeneration = client.getRunGeneration("chat-guidance");

    client.sendMessage("chat-guidance", "focus on sources", undefined, {
      turnId: "turn-guidance",
      startsNewRun: false,
    });

    expect(client.getRunGeneration("chat-guidance")).toBe(requestGeneration);
    expect(
      client.reconcileCanonicalCompletion(
        "chat-guidance",
        requestGeneration,
        ["turn-active"],
      ),
    ).toBe(true);
  });

  it("accepts an explicitly completed turn with no assistant row", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-empty-answer", "question", undefined, {
      turnId: "turn-empty-answer",
    });
    const requestGeneration = client.getRunGeneration("chat-empty-answer");

    expect(
      client.reconcileCanonicalCompletion(
        "chat-empty-answer",
        requestGeneration,
        ["turn-empty-answer"],
      ),
    ).toBe(true);
  });

  it.each([
    "message_rejected",
    "attachment_rejected",
    "workspace_scope_rejected",
  ])("settles a specifically rejected outbound turn (%s)", (detail) => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-rejected", "question", undefined, {
      turnId: "turn-rejected",
    });
    const requestGeneration = client.getRunGeneration("chat-rejected");

    expect(
      client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
    ).toBe(false);

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-rejected",
      turn_id: "turn-rejected",
      detail,
      reason: "policy",
    });

    expect(
      client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
    ).toBe(true);
  });

  it("does not let an older rejection settle or stop a newer run", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-rejection-race", "first", undefined, {
      turnId: "turn-old",
    });
    client.sendMessage("chat-rejection-race", "second", undefined, {
      turnId: "turn-new",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-rejection-race",
      status: "running",
      started_at: 2_000,
      turn_id: "turn-new",
    });
    const requestGeneration = client.getRunGeneration("chat-rejection-race");

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-rejection-race",
      turn_id: "turn-old",
      detail: "message_rejected",
      reason: "text_too_large",
    });

    expect(client.getRunStartedAt("chat-rejection-race")).toBe(2_000);
    expect(
      client.reconcileCanonicalCompletion("chat-rejection-race", requestGeneration, []),
    ).toBe(false);
  });

  it("restores the previous turn clock when the newer running turn is rejected", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-reject-newer-clock", "first", undefined, {
      turnId: "turn-clock-first",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-reject-newer-clock",
      status: "running",
      started_at: 1_000,
      turn_id: "turn-clock-first",
    });
    client.sendMessage("chat-reject-newer-clock", "second", undefined, {
      turnId: "turn-clock-second",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-reject-newer-clock",
      status: "running",
      started_at: 2_000,
      turn_id: "turn-clock-second",
    });

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-reject-newer-clock",
      turn_id: "turn-clock-second",
      detail: "message_rejected",
    });

    expect(client.getRunStartedAt("chat-reject-newer-clock")).toBe(1_000);
    expect(client.hasUnsettledRun("chat-reject-newer-clock")).toBe(true);
  });

  it("rolls back lifecycle sends that close 1009 before server acceptance", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-too-big", "oversized", undefined, {
      turnId: "turn-too-big",
    });
    const requestGeneration = client.getRunGeneration("chat-too-big");

    lastSocket().fakeCloseWithCode(1009);

    expect(errors).toEqual([{
      kind: "message_too_big",
      chatId: "chat-too-big",
      turnId: "turn-too-big",
    }]);
    expect(
      client.reconcileCanonicalCompletion("chat-too-big", requestGeneration, []),
    ).toBe(true);
  });

  it("preserves an accepted older run when a newer send closes 1009", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-too-big-race", "first", undefined, {
      turnId: "turn-accepted",
    });
    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-too-big-race",
      turn_id: "turn-accepted",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-too-big-race",
      status: "running",
      started_at: 1_000,
      turn_id: "turn-accepted",
    });
    client.sendMessage("chat-too-big-race", "oversized", undefined, {
      turnId: "turn-rejected",
    });
    const requestGeneration = client.getRunGeneration("chat-too-big-race");

    lastSocket().fakeCloseWithCode(1009);

    expect(errors).toEqual([{
      kind: "message_too_big",
      chatId: "chat-too-big-race",
      turnId: "turn-rejected",
    }]);
    expect(client.getRunStartedAt("chat-too-big-race")).toBe(1_000);
    expect(
      client.reconcileCanonicalCompletion(
        "chat-too-big-race",
        requestGeneration,
        [],
      ),
    ).toBe(false);
  });

  it("does not roll back a lifecycle send after its acceptance ACK", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-accepted", "question", undefined, {
      turnId: "turn-accepted",
    });
    const requestGeneration = client.getRunGeneration("chat-accepted");
    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-accepted",
      turn_id: "turn-accepted",
    });

    lastSocket().fakeCloseWithCode(1009);

    expect(
      client.reconcileCanonicalCompletion("chat-accepted", requestGeneration, []),
    ).toBe(false);
  });

  it("preflights exact websocket frame bytes and rejects only the oversized turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      maxFrameBytes: 180,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    const sentBefore = lastSocket().sent.length;

    client.sendMessage("chat-preflight-size", "x".repeat(500), undefined, {
      turnId: "turn-preflight-size",
    });

    expect(lastSocket().sent).toHaveLength(sentBefore);
    expect(client.hasUnsettledRun("chat-preflight-size")).toBe(false);
    expect(errors).toEqual([{
      kind: "message_too_big",
      chatId: "chat-preflight-size",
      turnId: "turn-preflight-size",
    }]);
  });

  it("does not attribute a fallback 1009 close across multiple unacknowledged chats", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-size-a", "first", undefined, { turnId: "turn-size-a" });
    client.sendMessage("chat-size-b", "second", undefined, { turnId: "turn-size-b" });

    lastSocket().fakeCloseWithCode(1009);

    expect(errors).toEqual([{ kind: "message_too_big" }]);
    expect(client.hasUnsettledRun("chat-size-a")).toBe(true);
    expect(client.hasUnsettledRun("chat-size-b")).toBe(true);
  });

  it("does not attribute 1009 to an unacknowledged message when another frame followed it", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-before-audio", "question", undefined, {
      turnId: "turn-before-audio",
    });
    const transcription = client.transcribeAudio("data:audio/webm;base64,AAAA");

    lastSocket().fakeCloseWithCode(1009);

    await expect(transcription).rejects.toThrow("socket closed");
    expect(errors).toEqual([{ kind: "message_too_big" }]);
    expect(client.hasUnsettledRun("chat-before-audio")).toBe(true);
  });

  it("settles an unknown send absent from an idle canonical snapshot after disconnect", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-never-arrived", "question", undefined, {
      turnId: "turn-never-arrived",
    });
    const requestGeneration = client.getRunGeneration("chat-never-arrived");

    lastSocket().close();

    const snapshot = {
      observedTurnIds: [],
      hasPendingToolCalls: false,
      activeTurnId: null,
    };
    expect(
      client.canReconcileCanonicalCompletion(
        "chat-never-arrived",
        requestGeneration,
        [],
        snapshot,
      ),
    ).toBe(true);
    expect(
      client.reconcileCanonicalCompletion(
        "chat-never-arrived",
        requestGeneration,
        [],
        snapshot,
      ),
    ).toBe(true);
    expect(client.hasUnsettledRun("chat-never-arrived")).toBe(false);
  });

  it("keeps an ACK-lost observed turn active, then settles it from an idle snapshot", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-ack-lost", "question", undefined, {
      turnId: "turn-ack-lost",
    });
    const requestGeneration = client.getRunGeneration("chat-ack-lost");
    lastSocket().close();

    expect(
      client.canReconcileCanonicalCompletion(
        "chat-ack-lost",
        requestGeneration,
        [],
        {
          observedTurnIds: ["turn-ack-lost"],
          hasPendingToolCalls: true,
          activeTurnId: "turn-ack-lost",
        },
      ),
    ).toBe(false);
    expect(
      client.reconcileCanonicalCompletion(
        "chat-ack-lost",
        requestGeneration,
        [],
        {
          observedTurnIds: ["turn-ack-lost"],
          hasPendingToolCalls: false,
          activeTurnId: null,
        },
      ),
    ).toBe(true);
    expect(client.hasUnsettledRun("chat-ack-lost")).toBe(false);
  });

  it("settles an accepted turn that never reached running from canonical idle", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-accepted-idle", "question", undefined, {
      turnId: "turn-accepted-idle",
    });
    const requestGeneration = client.getRunGeneration("chat-accepted-idle");
    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-accepted-idle",
      turn_id: "turn-accepted-idle",
    });

    expect(
      client.reconcileCanonicalCompletion(
        "chat-accepted-idle",
        requestGeneration,
        [],
        {
          observedTurnIds: ["turn-accepted-idle"],
          hasPendingToolCalls: false,
          activeTurnId: null,
        },
      ),
    ).toBe(true);
    expect(client.hasUnsettledRun("chat-accepted-idle")).toBe(false);
  });

  it("does not let a pre-send idle response erase a newly accepted turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const requestGeneration = client.getRunGeneration("chat-stale-idle");
    client.sendMessage("chat-stale-idle", "question", undefined, {
      turnId: "turn-after-request",
    });
    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-stale-idle",
      turn_id: "turn-after-request",
    });

    expect(
      client.reconcileCanonicalCompletion(
        "chat-stale-idle",
        requestGeneration,
        [],
        {
          observedTurnIds: [],
          hasPendingToolCalls: false,
          activeTurnId: null,
        },
      ),
    ).toBe(false);
    expect(client.hasUnsettledRun("chat-stale-idle")).toBe(true);
  });

  it("correlates a legacy rejection only to one currently sent turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-legacy-reject", "question", undefined, {
      turnId: "turn-legacy-reject",
    });

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-legacy-reject",
      detail: "message_rejected",
      reason: "text_too_large",
    });

    expect(client.hasUnsettledRun("chat-legacy-reject")).toBe(false);
    expect(errors).toEqual([expect.objectContaining({
      kind: "turn_rejected",
      chatId: "chat-legacy-reject",
      turnId: "turn-legacy-reject",
    })]);
  });

  it("correlates legacy lifecycle completion when exactly one turn is unsettled", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onChat("chat-legacy-idle", handler);
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-legacy-idle", "question", undefined, {
      turnId: "turn-legacy-idle",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-legacy-idle",
      status: "running",
      started_at: 4321,
    });

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-legacy-idle",
      status: "idle",
    });

    expect(client.hasUnsettledRun("chat-legacy-idle")).toBe(false);
    expect(client.getRunStartedAt("chat-legacy-idle")).toBeNull();
    expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({
      event: "goal_status",
      status: "idle",
      turn_id: "turn-legacy-idle",
    }));
  });

  it("does not apply an uncorrelated legacy idle to multiple unsettled turns", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onChat("chat-legacy-ambiguous", handler);
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-legacy-ambiguous", "first", undefined, {
      turnId: "turn-legacy-first",
    });
    client.sendMessage("chat-legacy-ambiguous", "second", undefined, {
      turnId: "turn-legacy-second",
    });
    handler.mockClear();

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-legacy-ambiguous",
      status: "idle",
    });

    expect(client.hasUnsettledRun("chat-legacy-ambiguous")).toBe(true);
    expect(handler).not.toHaveBeenCalled();
  });

  it("does not correlate a legacy scope error to an already accepted turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
    client.onError((error) => errors.push(error));
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-legacy-scope", "question", undefined, {
      turnId: "turn-already-accepted",
    });
    lastSocket().fakeMessage({
      event: "message_accepted",
      chat_id: "chat-legacy-scope",
      turn_id: "turn-already-accepted",
    });

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-legacy-scope",
      detail: "workspace_scope_rejected",
      reason: "chat_running",
    });

    expect(client.hasUnsettledRun("chat-legacy-scope")).toBe(true);
    expect(errors).toEqual([{
      kind: "workspace_scope_rejected",
      reason: "chat_running",
      chatId: "chat-legacy-scope",
      turnId: undefined,
    }]);
  });

  it("does not correlate a scope-control rejection to a preceding unacknowledged message", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-scope-control", "question", undefined, {
      turnId: "turn-before-scope-control",
    });
    client.setWorkspaceScope("chat-scope-control", {
      project_path: "/tmp/project",
      project_name: "project",
      access_mode: "restricted",
    });

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-scope-control",
      detail: "workspace_scope_rejected",
      reason: "chat_running",
    });

    expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
  });

  it("sends large sidebar ordering state as a correlated WebUI request", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const sessionOrder = Array.from(
      { length: 160 },
      (_, index) => `websocket:${index.toString().padStart(4, "0")}-${"x".repeat(48)}`,
    );
    const state: SidebarStatePayload = {
      schema_version: 1,
      pinned_keys: [],
      archived_keys: [],
      session_order: sessionOrder,
      title_overrides: {},
      project_name_overrides: {},
      tags_by_key: {},
      collapsed_groups: {},
      workbench: { version: 1, tabs: {} },
      view: {
        density: "comfortable",
        show_previews: false,
        show_timestamps: false,
        show_archived: false,
        sort: "manual",
      },
      updated_at: null,
    };

    client.connect();
    lastSocket().fakeOpen();
    const pending = client.setSidebarState(state);

    const [serialized] = lastSocket().sent;
    expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
    const request = JSON.parse(serialized) as {
      type: string;
      request_id: string;
      action: string;
      payload: { state: SidebarStatePayload };
    };
    expect(request).toEqual({
      type: "webui_request",
      request_id: expect.any(String),
      action: "sidebar.update",
      payload: { state },
    });
    lastSocket().fakeMessage({
      event: "webui_response",
      request_id: request.request_id,
      ok: true,
      result: state,
    });
    await expect(pending).resolves.toEqual(state);
  });

  it("delivers backend sidebar state updates to every subscriber", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    const state: SidebarStatePayload = {
      schema_version: 1,
      pinned_keys: [],
      archived_keys: [],
      session_order: [],
      title_overrides: {},
      project_name_overrides: {},
      tags_by_key: {},
      collapsed_groups: {},
      workbench: {
        version: 1,
        tabs: {
          "tab:websocket:a": {
            explicit: true,
            title: "Research",
            paneKeys: ["websocket:a", "websocket:b"],
            layout: "columns",
          },
        },
      },
      view: {
        density: "comfortable",
        show_previews: false,
        show_timestamps: false,
        show_archived: false,
        sort: "updated_desc",
      },
      updated_at: "2026-08-11T08:00:00Z",
    };

    client.onSidebarStateUpdate(handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({ event: "sidebar_state_updated", state });

    expect(handler).toHaveBeenCalledWith(state);
  });

  it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-unrelated-scope", "question", undefined, {
      turnId: "turn-unrelated-scope",
    });
    const pendingChat = client.newChat(5_000, {
      project_path: "/missing",
      project_name: "missing",
      access_mode: "restricted",
    });

    lastSocket().fakeMessage({
      event: "error",
      detail: "workspace_scope_rejected",
      reason: "project_path must be an existing directory",
    });

    await expect(pendingChat).rejects.toThrow("workspace_scope_rejected");
    expect(client.hasUnsettledRun("chat-unrelated-scope")).toBe(true);
  });

  it("rejects a correlated system command instead of leaving it pending", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const pending = client.sendSystemCommand("chat-system-reject", "/model invalid");
    const sent = JSON.parse(lastSocket().sent.at(-1) ?? "{}") as { turn_id?: string };
    expect(sent.turn_id).toMatch(/^webui-system:/);

    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-system-reject",
      turn_id: sent.turn_id,
      detail: "message_rejected",
      reason: "invalid_command",
    });

    await expect(pending).rejects.toThrow("message_rejected:invalid_command");
  });

  it("ignores a delayed idle event from an older turn after a new run starts", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatHandler = vi.fn();
    const runHandler = vi.fn();
    client.onChat("chat-delayed-idle", chatHandler);
    client.onRunStatus(runHandler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-delayed-idle",
      status: "running",
      started_at: 1_000,
      turn_id: "turn-old",
    });
    client.sendMessage("chat-delayed-idle", "next question", undefined, {
      turnId: "turn-new",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-delayed-idle",
      status: "running",
      started_at: 2_000,
      turn_id: "turn-new",
    });
    chatHandler.mockClear();
    runHandler.mockClear();

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-delayed-idle",
      status: "idle",
      turn_id: "turn-old",
    });

    expect(client.getRunStartedAt("chat-delayed-idle")).toBe(2_000);
    expect(runHandler).not.toHaveBeenCalled();
    expect(chatHandler).not.toHaveBeenCalled();
  });

  it("accepts a completed snapshot that represents a delayed running frame", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const requestGeneration = client.getRunGeneration("chat-delayed-run");

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-delayed-run",
      status: "running",
      started_at: 12_345,
      turn_id: "turn-complete",
    });

    expect(
      client.reconcileCanonicalCompletion(
        "chat-delayed-run",
        requestGeneration,
        ["turn-complete"],
      ),
    ).toBe(true);
    expect(client.getRunStartedAt("chat-delayed-run")).toBeNull();
  });

  it("preflights canonical completion without fencing or settling the turn", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatHandler = vi.fn();
    client.onChat("chat-preflight", chatHandler);
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-preflight", "question", undefined, {
      turnId: "turn-preflight",
    });
    const requestGeneration = client.getRunGeneration("chat-preflight");

    expect(
      client.canReconcileCanonicalCompletion(
        "chat-preflight",
        requestGeneration,
        ["turn-preflight"],
      ),
    ).toBe(true);
    expect(
      client.canReconcileCanonicalCompletion("chat-preflight", requestGeneration, []),
    ).toBe(false);

    lastSocket().fakeMessage({
      event: "delta",
      chat_id: "chat-preflight",
      turn_id: "turn-preflight",
      text: "still live",
    });
    expect(chatHandler).toHaveBeenCalledWith(
      expect.objectContaining({ event: "delta", text: "still live" }),
    );
  });

  it("clears the run cache and fences delayed frames after canonical completion", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatHandler = vi.fn();
    const runHandler = vi.fn();
    client.onChat("chat-canonical", chatHandler);
    client.onRunStatus(runHandler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-canonical",
      status: "running",
      started_at: 12_345,
      turn_id: "turn-canonical",
    });
    const requestGeneration = client.getRunGeneration("chat-canonical");

    expect(
      client.reconcileCanonicalCompletion(
        "chat-canonical",
        requestGeneration,
        ["turn-canonical"],
      ),
    ).toBe(true);
    expect(client.getRunStartedAt("chat-canonical")).toBeNull();
    expect(runHandler).toHaveBeenLastCalledWith("chat-canonical", null);
    const deliveredBeforeLateFrames = chatHandler.mock.calls.length;

    lastSocket().fakeMessage({
      event: "delta",
      chat_id: "chat-canonical",
      text: " delayed",
      turn_id: "turn-canonical",
    });
    lastSocket().fakeMessage({
      event: "turn_end",
      chat_id: "chat-canonical",
      turn_id: "turn-canonical",
    });
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-canonical",
      status: "idle",
      turn_id: "turn-canonical",
    });

    expect(chatHandler).toHaveBeenCalledTimes(deliveredBeforeLateFrames);
    expect(client.getRunStartedAt("chat-canonical")).toBeNull();
  });

  it("notifies run status subscribers and replays running chats", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onRunStatus(handler);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-status",
      status: "running",
      started_at: 12_345,
    });
    expect(handler).toHaveBeenCalledWith("chat-status", 12_345);

    const lateHandler = vi.fn();
    client.onRunStatus(lateHandler);
    expect(lateHandler).toHaveBeenCalledWith("chat-status", 12_345);

    lastSocket().fakeMessage({
      event: "goal_status",
      chat_id: "chat-status",
      status: "idle",
    });
    expect(handler).toHaveBeenCalledWith("chat-status", null);
    expect(lateHandler).toHaveBeenCalledWith("chat-status", null);
  });

  it("records goal_state per chat_id without an onChat subscriber", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "goal_state",
      chat_id: "chat-goal-a",
      goal_state: { active: true, ui_summary: "Docs" },
    });
    lastSocket().fakeMessage({
      event: "goal_state",
      chat_id: "chat-goal-b",
      goal_state: { active: true, objective: "Ship API" },
    });
    expect(client.getGoalState("chat-goal-a")).toEqual({ active: true, ui_summary: "Docs" });
    expect(client.getGoalState("chat-goal-b")).toEqual({
      active: true,
      objective: "Ship API",
    });
    lastSocket().fakeMessage({
      event: "goal_state",
      chat_id: "chat-goal-a",
      goal_state: { active: false },
    });
    expect(client.getGoalState("chat-goal-a")).toEqual({ active: false });
  });

  it("records goal_state from turn_end payload when present", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "turn_end",
      chat_id: "chat-te",
      goal_state: { active: true, objective: "Long task" },
    });
    expect(client.getGoalState("chat-te")).toEqual({ active: true, objective: "Long task" });
  });

  it("buffers after unsubscribe until the chat is subscribed again", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const h1 = vi.fn();
    const unsub = client.onChat("chat-rejoin", h1);
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "live" });
    expect(h1).toHaveBeenCalledTimes(1);
    unsub();
    lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "queued" });
    expect(h1).toHaveBeenCalledTimes(1);
    const h2 = vi.fn();
    client.onChat("chat-rejoin", h2);
    expect(h2).toHaveBeenCalledTimes(1);
    expect(h2.mock.calls[0][0]).toMatchObject({ event: "delta", text: "queued" });
  });

  it("dispatches runtime model updates globally", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onRuntimeModelUpdate(handler);
    client.connect();
    lastSocket().fakeOpen();

    lastSocket().fakeMessage({
      event: "runtime_model_updated",
      model_name: "openai/gpt-4.1",
      model_preset: "fast",
    });

    expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
  });

  it("dispatches turn model updates to the active chat", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatHandler = vi.fn();
    client.onChat("chat-a", chatHandler);
    client.connect();
    lastSocket().fakeOpen();

    lastSocket().fakeMessage({
      event: "turn_model_updated",
      chat_id: "chat-a",
      model_name: "deepseek/deepseek-chat",
      model_preset: "Deep Research",
      fallback: true,
    });

    expect(chatHandler).toHaveBeenCalledWith({
      event: "turn_model_updated",
      chat_id: "chat-a",
      model_name: "deepseek/deepseek-chat",
      model_preset: "Deep Research",
      fallback: true,
    });
  });

  it("dispatches session updates globally", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const globalHandler = vi.fn();
    const chatHandler = vi.fn();
    client.onSessionUpdate(globalHandler);
    client.onChat("chat-title", chatHandler);
    client.connect();
    lastSocket().fakeOpen();

    lastSocket().fakeMessage({
      event: "session_updated",
      chat_id: "chat-title",
      scope: "metadata",
      workspace_scope: {
        project_path: "/tmp/project",
        project_name: "project",
        access_mode: "restricted",
        restrict_to_workspace: true,
      },
    });

    expect(globalHandler).toHaveBeenCalledWith(
      "chat-title",
      "metadata",
      expect.objectContaining({ project_path: "/tmp/project" }),
    );
    expect(chatHandler).not.toHaveBeenCalled();
  });

  it("resolves newChat() via the server-assigned chat_id", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const promise = client.newChat(1_000);
    expect(lastSocket().sent).toContain(JSON.stringify({ type: "new_chat" }));
    lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
    await expect(promise).resolves.toBe("fresh-id");
  });

  it("serializes workspace scope for new chats and messages", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const workspaceScope = {
      project_path: "/tmp/project",
      project_name: "project",
      access_mode: "full" as const,
      restrict_to_workspace: false,
    };
    client.connect();
    lastSocket().fakeOpen();

    const promise = client.newChat(1_000, workspaceScope);
    expect(lastSocket().sent).toContain(
      JSON.stringify({ type: "new_chat", workspace_scope: workspaceScope }),
    );
    lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
    await expect(promise).resolves.toBe("fresh-id");

    client.sendMessage("fresh-id", "hello", undefined, { workspaceScope });
    expect(lastSocket().sent).toContain(
      JSON.stringify({
        type: "message",
        chat_id: "fresh-id",
        content: "hello",
        workspace_scope: workspaceScope,
        webui: true,
      }),
    );
  });

  it("sends transcription requests and resolves transcription results outside chat dispatch", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const handler = vi.fn();
    client.onChat("chat-a", handler);
    client.connect();
    lastSocket().fakeOpen();

    const promise = client.transcribeAudio("data:audio/webm;base64,AAAA", {
      durationMs: 1234,
      timeoutMs: 1_000,
    });
    const frame = JSON.parse(lastSocket().sent.at(-1) as string);
    expect(frame).toMatchObject({
      type: "transcribe_audio",
      data_url: "data:audio/webm;base64,AAAA",
      duration_ms: 1234,
    });
    expect(typeof frame.request_id).toBe("string");

    lastSocket().fakeMessage({
      event: "transcription_result",
      request_id: frame.request_id,
      text: "hello from voice",
    });
    await expect(promise).resolves.toBe("hello from voice");
    expect(handler).not.toHaveBeenCalled();
  });

  it("rejects pending transcription requests on server errors and socket close", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    const errored = client.transcribeAudio("data:audio/webm;base64,AAAA", { timeoutMs: 1_000 });
    const errorFrame = JSON.parse(lastSocket().sent.at(-1) as string);
    lastSocket().fakeMessage({
      event: "transcription_error",
      request_id: errorFrame.request_id,
      detail: "not_configured",
    });
    await expect(errored).rejects.toThrow("not_configured");

    const dropped = client.transcribeAudio("data:audio/webm;base64,BBBB", { timeoutMs: 1_000 });
    lastSocket().close();
    await expect(dropped).rejects.toThrow("socket closed");
  });

  it("queues sends while connecting and flushes on open", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    client.sendMessage("chat-x", "hello");
    expect(lastSocket().sent).toEqual([]);
    lastSocket().fakeOpen();
    // Attach is sent first because sendMessage adds to knownChats, which
    // handleOpen re-attaches; then the queued message follows.
    expect(lastSocket().sent).toContain(
      JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
    );
  });

  it("includes an explicit turn id on outbound WebUI messages", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-x", "hello", undefined, { turnId: "turn-1" });
    expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
      type: "message",
      chat_id: "chat-x",
      content: "hello",
      turn_id: "turn-1",
      webui: true,
    });
  });

  it("handles the silent system-command lifecycle without hiding concurrent events", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const chatHandler = vi.fn();
    client.onChat("chat-x", chatHandler);
    client.connect();
    lastSocket().fakeOpen();

    const pending = client.sendSystemCommand("chat-x", "  /model fast  ", 1_000);
    const frame = JSON.parse(lastSocket().sent.at(-1) as string);
    expect(frame).toMatchObject({
      type: "message",
      chat_id: "chat-x",
      content: "/model fast",
      webui: true,
    });
    expect(frame.turn_id).toMatch(/^webui-system:/);

    lastSocket().fakeMessage({
      event: "message",
      chat_id: "chat-x",
      text: "normal reply",
      turn_id: "normal-turn",
    });
    lastSocket().fakeMessage({
      event: "message",
      chat_id: "chat-x",
      text: "Switched model preset to fast.",
      turn_id: frame.turn_id,
    });

    await expect(pending).resolves.toBeUndefined();
    expect(chatHandler).toHaveBeenCalledTimes(1);
    expect(chatHandler).toHaveBeenCalledWith(expect.objectContaining({
      text: "normal reply",
      turn_id: "normal-turn",
    }));
    const interrupted = client.sendSystemCommand("chat-x", "/model fast", 1_000);
    lastSocket().close();
    await expect(interrupted).rejects.toThrow("socket closed");
  });

  it("sends selected assistant text as separate quoted context", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    client.sendMessage("chat-x", "What does this mean?", undefined, {
      quotedContext: "  selected answer excerpt  ",
    });

    expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
      type: "message",
      chat_id: "chat-x",
      content: "What does this mean?",
      quoted_context: "selected answer excerpt",
      webui: true,
    });
  });

  it("includes CLI app attachments in outbound messages", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    client.sendMessage(
      "chat-cli",
      "@drawio please make this diagram",
      undefined,
      {
        cliApps: [{
          name: "drawio",
          display_name: "Draw.io",
          category: "diagrams",
          entry_point: "cli-anything-drawio",
          logo_url: null,
          brand_color: "#F08705",
        }],
      },
    );

    expect(lastSocket().sent).toContain(
      JSON.stringify({
        type: "message",
        chat_id: "chat-cli",
        content: "@drawio please make this diagram",
        cli_apps: [{
          name: "drawio",
          display_name: "Draw.io",
          category: "diagrams",
          entry_point: "cli-anything-drawio",
          logo_url: null,
          brand_color: "#F08705",
        }],
        webui: true,
      }),
    );
  });

  it("includes MCP preset attachments in outbound messages", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    client.sendMessage(
      "chat-mcp",
      "@browserbase check this page",
      undefined,
      {
        mcpPresets: [{
          name: "browserbase",
          display_name: "Browserbase",
          category: "browser",
          transport: "streamableHttp",
          status: "configured",
          configured: true,
          logo_url: "https://example.invalid/browserbase.svg",
          brand_color: "#111827",
        }],
      },
    );

    expect(lastSocket().sent).toContain(
      JSON.stringify({
        type: "message",
        chat_id: "chat-mcp",
        content: "@browserbase check this page",
        mcp_presets: [{
          name: "browserbase",
          display_name: "Browserbase",
          category: "browser",
          transport: "streamableHttp",
          status: "configured",
          configured: true,
          logo_url: "https://example.invalid/browserbase.svg",
          brand_color: "#111827",
        }],
        webui: true,
      }),
    );
  });

  it("includes session mentions in outbound messages", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();

    client.sendMessage("chat-current", "Use @pricing", undefined, {
      sessionMentions: [{
        name: "pricing",
        session_key: "websocket:pricing",
        title: "Pricing",
      }],
    });

    expect(lastSocket().sent).toContain(JSON.stringify({
      type: "message",
      chat_id: "chat-current",
      content: "Use @pricing",
      session_mentions: [{
        name: "pricing",
        session_key: "websocket:pricing",
        title: "Pricing",
      }],
      webui: true,
    }));
  });

  it("re-attaches known chats after a reconnect", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 10,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.onChat("chat-z", () => {});
    client.connect();
    lastSocket().fakeOpen();
    expect(lastSocket().sent).toContain(
      JSON.stringify({ type: "attach", chat_id: "chat-z" }),
    );
    // Drop the socket.
    lastSocket().close();
    // Advance the backoff timer.
    await vi.advanceTimersByTimeAsync(20);
    const reconnected = lastSocket();
    expect(reconnected).not.toBe(FakeSocket.instances[0]);
    reconnected.fakeOpen();
    expect(reconnected.sent).toContain(
      JSON.stringify({ type: "attach", chat_id: "chat-z" }),
    );
  });

  it("reports status transitions through onStatus", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const seen: string[] = [];
    client.onStatus((s) => seen.push(s));
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().close();
    expect(seen).toEqual(["idle", "connecting", "open", "closed"]);
  });

  it("does not schedule a reconnect when close() is called explicitly", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 10,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const seen: string[] = [];
    client.onStatus((s) => seen.push(s));
    client.connect();
    lastSocket().fakeOpen();
    client.close();
    // Advance past any possible backoff window to prove no reconnect was scheduled.
    await vi.advanceTimersByTimeAsync(200);
    expect(FakeSocket.instances).toHaveLength(1);
    // "reconnecting" must never appear after an intentional close.
    expect(seen).not.toContain("reconnecting");
    expect(seen.at(-1)).toBe("closed");
  });

  it("passes media through into the message envelope", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-x", "look", [
      { data_url: "data:image/png;base64,AAAA", name: "shot.png" },
    ]);
    const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
    expect(lastFrame).toEqual({
      type: "message",
      chat_id: "chat-x",
      content: "look",
      media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
      webui: true,
    });
  });

  it("omits media from the envelope when no images are attached", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    client.sendMessage("chat-x", "hello");
    const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
    expect(lastFrame).not.toHaveProperty("media");
    expect(lastFrame).toEqual({
      type: "message",
      chat_id: "chat-x",
      content: "hello",
      webui: true,
    });
  });

  it("emits a message_too_big error when the socket closes with code 1009", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string }> = [];
    client.onError((e) => errors.push(e));
    client.connect();
    lastSocket().fakeOpen();
    // Server rejected an outbound frame as too large.
    lastSocket().fakeCloseWithCode(1009);
    expect(errors).toEqual([{ kind: "message_too_big" }]);
  });

  it("emits workspace scope rejection errors from server frames", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string; reason?: string; chatId?: string }> = [];
    client.onError((e) => errors.push(e));
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeMessage({
      event: "error",
      chat_id: "chat-a",
      detail: "workspace_scope_rejected",
      reason: "chat_running",
    });
    expect(errors).toEqual([
      {
        kind: "workspace_scope_rejected",
        reason: "chat_running",
        chatId: "chat-a",
      },
    ]);
  });

  it("rejects pending new chats when workspace scope is rejected", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    client.connect();
    lastSocket().fakeOpen();
    const pending = client.newChat(5_000, {
      project_path: "/missing",
      project_name: "missing",
      access_mode: "restricted",
    });
    lastSocket().fakeMessage({
      event: "error",
      detail: "workspace_scope_rejected",
      reason: "project_path must be an existing directory",
    });
    await expect(pending).rejects.toThrow("workspace_scope_rejected");
  });

  it("isolates throwing error handlers so reconnect bookkeeping still runs", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 5,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    // First handler explodes; subsequent reconnect state must be untouched.
    client.onError(() => {
      throw new Error("subscriber blew up");
    });
    const seenStatuses: string[] = [];
    client.onStatus((s) => seenStatuses.push(s));
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().fakeCloseWithCode(1009);
    // Despite the throwing handler, the client must still schedule a reconnect.
    expect(seenStatuses).toContain("reconnecting");
    await vi.advanceTimersByTimeAsync(20);
    expect(FakeSocket.instances.length).toBeGreaterThan(1);
  });

  it("does not emit a stream error on a vanilla socket close", () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: false,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const errors: Array<{ kind: string }> = [];
    client.onError((e) => errors.push(e));
    client.connect();
    lastSocket().fakeOpen();
    lastSocket().close();
    expect(errors).toEqual([]);
  });

  it("surfaces 'reconnecting' only on an unexpected drop", async () => {
    const client = new NanobotClient({
      url: "ws://test",
      reconnect: true,
      maxBackoffMs: 5,
      socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
    });
    const seen: string[] = [];
    client.onStatus((s) => seen.push(s));
    client.connect();
    lastSocket().fakeOpen();
    // Simulate the remote side hanging up (no client.close() call).
    lastSocket().close();
    await vi.advanceTimersByTimeAsync(50);
    expect(seen).toContain("reconnecting");
    expect(FakeSocket.instances.length).toBeGreaterThan(1);
  });
});
