Ctrl K
ComunidadGuías…

tests/e2e/skills-invocation.spec.ts

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

Archivo original ↗Preguntar a KiBot
import { expect, test, type Page } from "@playwright/test";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import {
  fulfillJson,
  mockBaseChrome,
  modelId,
} from "./helpers/automation-control-plane-fixtures";
import {
  buildUiMessageStreamBody,
  mockBaseApp,
  modelId as workspaceModelId,
  repo as workspaceRepo,
} from "./helpers/diff-rendering-fixtures";

const CATALOG = {
  skills: [
    {
      id: "skill-1",
      slug: "deploy-checklist",
      name: "Deploy checklist",
      description: "Steps before shipping",
      source: "library",
    },
    {
      id: "skill-2",
      slug: "release-notes",
      name: "Release notes",
      description: "Write the changelog entry",
      source: "library",
    },
  ],
};

async function mockSkillCatalog(page: Page) {
  const requests: string[] = [];
  await page.route("**/api/skills/catalog**", (route) => {
    requests.push(route.request().url());
    return fulfillJson(route, CATALOG);
  });
  return requests;
}

test("Control completes a skill handle and sends the message exactly as typed", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  const catalogRequests = await mockSkillCatalog(page);
  await page.route("**/api/models", (route) =>
    fulfillJson(route, {
      models: [{ id: modelId, context_length: 128000 }],
      catalog: [{ id: modelId, context_length: 128000, is_enabled: true }],
      default_model: modelId,
    })
  );
  await page.route("**/api/connections", (route) =>
    fulfillJson(route, { connections: [] })
  );
  await page.route("**/api/repos**", (route) =>
    fulfillJson(route, [
      {
        id: "repo-1",
        full_name: "acme/widgets",
        owner: "acme",
        name: "widgets",
        default_branch: "main",
      },
    ])
  );
  await page.route("**/api/control/sessions**", (route) => {
    if (route.request().method() !== "POST") return route.fallback();
    return fulfillJson(route, {
      id: "sess-skills-1",
      title: "Session",
      project: null,
      repo_id: "repo-1",
      model_id: modelId,
      pinned: false,
      archived: false,
      created_at: "2026-09-21T00:00:00.000Z",
      updated_at: "2026-09-21T00:00:00.000Z",
      messages: [],
    });
  });
  const sentTexts: string[] = [];
  await page.route("**/api/control/chat", (route) => {
    const body = route.request().postDataJSON() as {
      messages?: Array<{ role?: string; parts?: Array<{ text?: string }> }>;
    };
    const lastUser = body.messages?.findLast((m) => m.role === "user");
    sentTexts.push(
      lastUser?.parts?.map((part) => part.text ?? "").join("") ?? ""
    );
    const chunks = [
      { type: "start" },
      { type: "text-start", id: "t1" },
      {
        type: "text-delta",
        id: "t1",
        delta: "Following the release notes skill.",
      },
      { type: "text-end", id: "t1" },
      { type: "finish" },
    ];
    return route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body:
        chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
        "data: [DONE]\n\n",
    });
  });

  await page.goto(scopedPath("control"));
  const composer = page.getByPlaceholder("Ask anything or run a command...");
  await expect(composer).toBeVisible();

  // Nothing is offered for ordinary text.
  await composer.fill("Draft the notes with ");
  const menu = page.getByRole("listbox", { name: "Skills" });
  await expect(menu).toHaveCount(0);

  // A dollar handle opens the menu anywhere in the message and narrows as typed.
  await composer.pressSequentially("$");
  await expect(menu.getByRole("option")).toHaveCount(2);
  await composer.pressSequentially("rel");
  await expect(menu.getByRole("option")).toHaveCount(1);
  await expect(menu).toContainText("$release-notes");
  await expect(menu).toContainText("Write the changelog entry");

  // Enter picks the skill instead of sending a half-typed handle.
  await composer.press("Enter");
  await expect(composer).toHaveValue("Draft the notes with $release-notes ");
  await expect(menu).toHaveCount(0);
  expect(sentTexts).toEqual([]);

  // Escape dismisses without changing the text.
  await composer.pressSequentially("and $dep");
  await expect(menu).toContainText("$deploy-checklist");
  await composer.press("Escape");
  await expect(menu).toHaveCount(0);
  await expect(composer).toHaveValue(
    "Draft the notes with $release-notes and $dep"
  );

  await composer.fill("Draft the notes with $release-notes for v2");
  await page.getByRole("button", { name: "Start mission" }).click();
  await expect(
    page
      .getByRole("log", { name: "Conversation" })
      .getByText("Following the release notes skill.")
  ).toBeVisible();
  // The handle reaches the server untouched; the server loads the skill.
  expect(sentTexts).toEqual(["Draft the notes with $release-notes for v2"]);
  // Fixture repo ids are not UUIDs, so the library catalog is requested bare.
  expect(catalogRequests.every((url) => !url.includes("repoId="))).toBe(true);

  // The follow-up composer completes a leading slash handle the same way.
  const followUp = page.getByLabel("Ask for follow-up changes");
  await followUp.pressSequentially("/dep");
  await expect(page.getByTestId("control-skill-suggestions")).toContainText(
    "$deploy-checklist"
  );
  await followUp.press("Tab");
  await expect(followUp).toHaveValue("/deploy-checklist ");
});

test("a composer still sends a skill handle when completion cannot load", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseApp(page);
  // Completion is a convenience: the server resolves the handle on its own.
  await page.route("**/api/skills/catalog**", (route) =>
    fulfillJson(route, { error: "Failed to load skills" }, 500)
  );
  await page.route("**/api/commands", (route) => fulfillJson(route, []));
  await page.route(/\/api\/conversations(?:\?.*)?$/, async (route) => {
    if (route.request().method() === "GET") {
      await fulfillJson(route, {
        messages: [],
        local_msgs: [],
        model: workspaceModelId,
        mode: "AUTO",
      });
      return;
    }
    await fulfillJson(route, { ok: true });
  });
  const sentTexts: string[] = [];
  await page.route("**/api/chat", async (route) => {
    const body = route.request().postDataJSON() as {
      messages?: Array<{ role?: string; parts?: Array<{ text?: string }> }>;
    };
    const lastUser = body.messages?.findLast((m) => m.role === "user");
    sentTexts.push(
      lastUser?.parts?.map((part) => part.text ?? "").join("") ?? ""
    );
    await route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body: buildUiMessageStreamBody("Notes drafted."),
    });
  });

  await page.goto(scopedPath("projects/workspace"));
  await page.waitForLoadState("networkidle");
  await page.getByTestId(`home-open-workspace-${workspaceRepo.id}`).click();
  const composer = page.getByRole("textbox", {
    name: "Ask the agent what to build, fix, or explain. Type / for commands or drop files here.",
  });
  // The slash menu only opens once the composer is hydrated; sending before
  // that would be a keypress into inert markup.
  await composer.fill("/he");
  await expect(page.getByText("Show available commands")).toBeVisible();
  await composer.fill("Draft them with $release-notes");
  await composer.press("Enter");
  await expect(page.getByText("Notes drafted.")).toBeVisible();
  expect(sentTexts).toEqual(["Draft them with $release-notes"]);
});

test("the skills library shows the handle each skill answers to", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  await mockSkillCatalog(page);
  await page.route("**/api/skills/registry**", (route) =>
    fulfillJson(route, { skills: [] })
  );
  await page.route("**/api/skills", (route) =>
    fulfillJson(
      route,
      CATALOG.skills.map((skill) => ({
        id: skill.id,
        name: skill.name,
        description: skill.description,
        content: "…",
        is_public: false,
        tags: [],
        usage_count: 0,
        scope: "global",
        created_at: "2026-09-01T00:00:00.000Z",
        updated_at: "2026-09-01T00:00:00.000Z",
      }))
    )
  );

  await page.goto(scopedPath("agents/skills"));
  await page.getByRole("tab", { name: /Installed/ }).click();
  await expect(page.getByText("Deploy checklist")).toBeVisible();
  await expect(page.getByTestId("skill-handle")).toHaveText([
    "$deploy-checklist",
    "$release-notes",
  ]);
});

test("the workspace composer completes /skill and sends it instead of rejecting an unknown command", async ({
  page,
}) => {
  // Finish terminal startup after the user is already composing. Its async
  // initialization must not redirect the next keystroke into the terminal.
  const chunksDir = join(process.cwd(), ".next/static/chunks");
  const terminalChunks = new Set(
    readdirSync(chunksDir).filter(
      (file) =>
        file.endsWith(".js") &&
        readFileSync(join(chunksDir, file), "utf8").includes(
          "wterm: failed to initialize:"
        )
    )
  );
  expect(terminalChunks.size).toBeGreaterThan(0);
  let releaseTerminal!: () => void;
  const terminalReady = new Promise<void>((resolve) => {
    releaseTerminal = resolve;
  });
  let reportTerminalRequest!: () => void;
  const terminalRequested = new Promise<void>((resolve) => {
    reportTerminalRequest = resolve;
  });
  await page.route("**/_next/static/chunks/*.js*", async (route) => {
    const filename = new URL(route.request().url()).pathname.split("/").at(-1);
    if (filename && terminalChunks.has(filename)) {
      reportTerminalRequest();
      await terminalReady;
    }
    await route.continue();
  });
  await enableScopedE2EAuth(page);
  await mockBaseApp(page);
  await mockSkillCatalog(page);
  await page.route("**/api/commands", (route) => fulfillJson(route, []));
  await page.route(/\/api\/conversations(?:\?.*)?$/, async (route) => {
    if (route.request().method() === "GET") {
      await fulfillJson(route, {
        messages: [],
        local_msgs: [],
        model: workspaceModelId,
        mode: "AUTO",
      });
      return;
    }
    await fulfillJson(route, { ok: true });
  });
  const sentTexts: string[] = [];
  await page.route("**/api/chat", async (route) => {
    const body = route.request().postDataJSON() as {
      messages?: Array<{ role?: string; parts?: Array<{ text?: string }> }>;
    };
    const lastUser = body.messages?.findLast((m) => m.role === "user");
    sentTexts.push(
      lastUser?.parts?.map((part) => part.text ?? "").join("") ?? ""
    );
    await route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body: buildUiMessageStreamBody("Checklist done."),
    });
  });

  await page.goto(scopedPath("projects/workspace"));
  await page.waitForLoadState("networkidle");
  await page.getByTestId(`home-open-workspace-${workspaceRepo.id}`).click();
  const composer = page.getByRole("textbox", {
    name: "Ask the agent what to build, fix, or explain. Type / for commands or drop files here.",
  });

  // Skills sit in the slash menu beside the built-ins.
  await composer.fill("/dep");
  await expect(page.getByText("Steps before shipping")).toBeVisible();
  await page.keyboard.press("Tab");
  await expect(composer).toHaveValue("/deploy-checklist ");

  await composer.pressSequentially("staging");
  await terminalRequested;
  releaseTerminal();
  const terminal = page.getByRole("textbox", { name: "Terminal", exact: true });
  await expect(terminal.locator(".term-row").first()).toBeAttached();
  await expect(composer).toBeFocused();
  await page.keyboard.press("Enter");
  await expect(page.getByText("Checklist done.")).toBeVisible();
  expect(sentTexts).toEqual(["/deploy-checklist staging"]);
  await expect(page.getByText(/Unknown command/)).toHaveCount(0);
  await expect(page.getByTestId("agent-running-indicator")).toHaveCount(0);
  await expect(
    page.getByRole("alert").filter({ hasText: "Chat response interrupted." })
  ).toHaveCount(0);
  // Explicitly clicking the terminal still chooses it for keyboard input.
  await terminal.click();
  await expect(terminal.locator("textarea")).toBeFocused();
});