Ctrl K
ComunidadGuías…

lib/chat-input-stop.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @vitest-environment jsdom

import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";

const h = vi.hoisted(() => ({
  uploads: {
    pending: [], inputs: [], uploading: false,
    addFiles: vi.fn(), remove: vi.fn(), clear: vi.fn(),
  },
  fetch: vi.fn(),
}));

vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));
vi.mock("./auth-context", () => ({ useAuth: () => ({ user: null }), useAuthOptional: () => null }));
vi.mock("./use-attachment-uploads", () => ({ useAttachmentUploads: () => h.uploads }));
vi.mock("./analytics", () => ({ trackEvent: vi.fn() }));
// Load the actual primitives without the package barrel's unrelated emoji data.
vi.mock("@/components/theme-provider", async (importOriginal) => ({
  ...(await importOriginal<Record<string, unknown>>()),
  useTheme: () => ({ resolvedTheme: "light" }),
}));

import { ChatInput } from "@/components/assistant/chat-input";
import { useAssistantChat } from "./use-assistant-chat";

let root: Root;
let container: HTMLDivElement;

beforeEach(() => {
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
  vi.stubGlobal("fetch", h.fetch);
  // The message field fades its overflowing edges, measured by a
  // ResizeObserver; jsdom has none.
  vi.stubGlobal("ResizeObserver", class {
    observe() {}
    unobserve() {}
    disconnect() {}
  });
  container = document.createElement("div");
  document.body.append(container);
  root = createRoot(container);
});

afterEach(() => {
  act(() => root.unmount());
  container.remove();
  vi.clearAllMocks();
  vi.unstubAllGlobals();
});

async function render(onAbort: () => void, sendWhileStreaming: boolean, draftHtml = "") {
  await act(async () => {
    root.render(createElement(TooltipProvider, {
      children: createElement(ChatInput, {
        onSend: vi.fn(), onAbort, isStreaming: true, sendWhileStreaming,
        hideAttach: true, draftHtml,
      }),
    }));
  });
}

describe("shared chat composer Stop button", () => {
  it("sends the durable Stop request from the real composer click while a tool is executing", async () => {
    let streamController!: ReadableStreamDefaultController<Uint8Array>;
    let chat!: ReturnType<typeof useAssistantChat>;
    const stream = new ReadableStream<Uint8Array>({
      start(controller) { streamController = controller; },
    });
    h.fetch.mockImplementation(async (url: string) => {
      if (url === "/api/assistant/chat") return new Response(stream, {
        headers: { "X-Numo-Conversation-Id": "51700000-0000-4000-8000-000000000001" },
      });
      if (url === "/api/assistant/turns/stop") return Response.json({ status: "stopped" });
      if (url.includes("/status")) return Response.json({ status: "stopped", activity: [] });
      if (url.includes("/messages")) return Response.json([]);
      throw new Error(`Unexpected request: ${url}`);
    });
    function Harness() {
      chat = useAssistantChat();
      const busy = ["streaming", "executing_tool", "generating_server"].includes(chat.state.status);
      return createElement(TooltipProvider, {
        children: createElement(ChatInput, {
          hideAttach: true, isStreaming: busy, onAbort: chat.abort,
          onSend: (message: string) => { void chat.sendMessage(null, message); },
        }),
      });
    }
    await act(async () => root.render(createElement(Harness)));
    const editor = container.querySelector<HTMLElement>('[contenteditable="true"]')!;
    await act(async () => {
      editor.textContent = "List my projects and issues";
      editor.dispatchEvent(new InputEvent("input", { bubbles: true }));
    });
    await act(async () => editor.dispatchEvent(new KeyboardEvent("keydown", {
      key: "Enter", ctrlKey: true, bubbles: true,
    })));
    await act(async () => streamController.enqueue(new TextEncoder().encode(
      'event: tool_call_start\ndata: {"id":"call-1","name":"list_projects"}\n\n',
    )));
    expect(chat.state.status).toBe("executing_tool");
    const stop = container.querySelector<HTMLButtonElement>('button[aria-label="stop"]')!;
    await act(async () => stop.click());
    expect(h.fetch.mock.calls.filter(([url]) => url === "/api/assistant/turns/stop")).toHaveLength(1);
    expect(chat.state.status).toBe("idle");
    expect(document.querySelector('[role="dialog"]')).toBeNull();
    await act(async () => {
      streamController.enqueue(new TextEncoder().encode(
        'event: content_delta\ndata: {"delta":"late final answer"}\n\n'
        + 'event: done\ndata: {"status":"stopped"}\n\n',
      ));
      streamController.close();
    });
    expect(chat.state.status).toBe("idle");
    expect(chat.state.streamingContent).toBe("");
  });

  it("calls the supplied abort handler on the first click during an assistant tool round", async () => {
    const onAbort = vi.fn();
    await render(onAbort, false, "A draft retained while Numo is working");
    const stop = container.querySelector<HTMLButtonElement>('button[aria-label="stop"]');
    expect(stop).not.toBeNull();
    await act(async () => stop!.click());
    expect(onAbort).toHaveBeenCalledTimes(1);
    expect(document.querySelector('[role="dialog"]')).toBeNull();
    expect(container.querySelector('[contenteditable]')?.textContent).toBe("A draft retained while Numo is working");
  });

  it("stops an agent on the first click while its steering composer is empty", async () => {
    const onAbort = vi.fn();
    await render(onAbort, true);
    const stop = container.querySelector<HTMLButtonElement>('button[aria-label="stop"]');
    expect(stop).not.toBeNull();
    await act(async () => stop!.click());
    expect(onAbort).toHaveBeenCalledTimes(1);
  });

  it("keeps the send action available for a worker steering draft", async () => {
    const onAbort = vi.fn();
    await render(onAbort, true, "Steer the running worker");
    expect(container.querySelector('button[aria-label="stop"]')).toBeNull();
    expect(container.querySelector('[contenteditable]')?.textContent).toBe("Steer the running worker");
    expect(onAbort).not.toHaveBeenCalled();
  });
});