Ctrl K
ComunidadGuías…

tests/e2e/control-composer.spec.ts

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

Archivo original ↗Preguntar a KiBot
import { expect, test } from "@playwright/test";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import {
  fulfillJson,
  mockBaseChrome,
  mockControlSessionBootstrap,
  modelId,
} from "./helpers/automation-control-plane-fixtures";

const shortModel = modelId.split("/").pop()!;
test("control composers expose permissions, model, and MCP controls without a spend cap", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  // Registered after mockBaseChrome so it wins: the composer's model chip
  // must follow the account default from /api/models.
  await page.route("**/api/models", (route) =>
    fulfillJson(route, {
      models: [
        { id: modelId, context_length: 128000 },
        { id: "anthropic/claude-sonnet-5", context_length: 200000 },
      ],
      catalog: [
        { id: modelId, context_length: 128000, is_enabled: true },
        {
          id: "anthropic/claude-sonnet-5",
          context_length: 200000,
          is_enabled: true,
        },
      ],
      default_model: modelId,
    })
  );
  await page.route("**/api/connections", (route) =>
    fulfillJson(route, { connections: [] })
  );
  // One connected repo: the composer must default the session's project to it.
  await page.route("**/api/repos**", (route) =>
    fulfillJson(route, [
      {
        id: "repo-1",
        full_name: "acme/widgets",
        owner: "acme",
        name: "widgets",
        default_branch: "main",
      },
      {
        id: "repo-2",
        full_name: "acme/roadmap",
        owner: "acme",
        name: "roadmap",
        default_branch: "main",
      },
    ])
  );
  const sessionCreates: Array<{
    title?: string;
    project?: string | null;
    repo_id?: string | null;
    model_id?: string | null;
  }> = [];
  const sessionUpdates: Array<Record<string, unknown>> = [];
  let sessionRecord: Record<string, unknown> | null = null;
  await page.route("**/api/control/sessions**", (route) => {
    const request = route.request();
    if (request.method() === "PUT" && sessionRecord) {
      const body = request.postDataJSON() as Record<string, unknown>;
      sessionUpdates.push(body);
      sessionRecord = {
        ...sessionRecord,
        ...body,
        updated_at: new Date().toISOString(),
      };
      return fulfillJson(route, { ok: true, session: sessionRecord });
    }
    if (request.method() === "GET")
      return fulfillJson(
        route,
        new URL(request.url()).searchParams.has("id")
          ? sessionRecord
          : sessionRecord
            ? [sessionRecord]
            : []
      );
    if (request.method() !== "POST") return route.fallback();
    const body = request.postDataJSON() as {
      title?: string;
      project?: string | null;
      repo_id?: string | null;
      model_id?: string | null;
    };
    sessionCreates.push(body);
    sessionRecord = {
      id: "sess-e2e-1",
      title: body.title ?? "Session",
      project: body.project ?? null,
      repo_id: body.repo_id ?? null,
      model_id: body.model_id ?? null,
      pinned: false,
      archived: false,
      created_at: "2026-08-11T00:00:00.000Z",
      updated_at: "2026-08-11T00:00:00.000Z",
      messages: [],
    };
    return fulfillJson(route, sessionRecord);
  });
  const chatRequests: Array<{
    messages?: Array<{
      role?: string;
      parts?: Array<{
        type: string;
        text?: string;
        filename?: string;
        mediaType?: string;
        url?: string;
      }>;
    }>;
    model?: string;
    mode?: string;
    permissions?: string;
    scope?: string;
    target?: string;
    conversationId?: string | null;
    missionId?: string | null;
    missionTitle?: string | null;
    repoId?: string | null;
    repoFullName?: string | null;
    repoOwner?: string | null;
    repoName?: string | null;
    repoBranch?: string | null;
    repoBaseBranch?: string | null;
    sandboxId?: string | null;
  }> = [];
  // A minimal but real UI message stream: text reply plus one tool call, so
  // the timeline's MOGPLEX and TOOL rendering is exercised end to end.
  const streamChunks = [
    { type: "start" },
    { type: "text-start", id: "t1" },
    { type: "text-delta", id: "t1", delta: "I can plan, delegate, and ship." },
    { type: "text-end", id: "t1" },
    {
      type: "tool-input-available",
      toolCallId: "call-1",
      toolName: "list_worktrees",
      input: { missionId: "MSN-1" },
    },
    { type: "finish" },
  ];
  const streamBody =
    streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
    "data: [DONE]\n\n";
  await page.route("**/api/control/chat", (route) => {
    chatRequests.push(
      route.request().postDataJSON() as (typeof chatRequests)[number]
    );
    return route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body: streamBody,
    });
  });
  await page.goto(scopedPath("control"));
  await page.waitForLoadState("networkidle");
  // New-mission composer: permissions defaults to Skip Permissions (amber
  // warning), cycles to Approve Edits (blue), and no dollar spend-cap chip
  // exists anywhere.
  await expect(page.getByText("Describe the outcome")).toBeVisible();
  // The session's project defaults to the connected repo.
  const projectPicker = page.getByLabel("Project", { exact: true });
  await expect(projectPicker).toBeVisible();
  await expect(projectPicker).toHaveAttribute("role", "combobox");
  await expect(projectPicker).toContainText("acme/widgets");
  await projectPicker.click();
  const projectSearch = page.getByLabel("Search projects", { exact: true });
  await expect(projectSearch).toBeFocused();
  await projectSearch.pressSequentially("roadmap");
  await expect(projectSearch).toHaveValue("roadmap");
  await expect(
    page.getByRole("option", { name: "acme/roadmap" })
  ).toBeVisible();
  await expect(page.getByRole("option", { name: "acme/widgets" })).toBeHidden();
  await projectSearch.press("Enter");
  await expect(projectPicker).toContainText("acme/roadmap");
  await projectPicker.click();
  await projectSearch.press("ArrowUp");
  const roadmapOption = page.getByRole("option", { name: "acme/roadmap" });
  await expect(roadmapOption).toBeFocused();
  await roadmapOption.press("Space");
  await expect(projectPicker).toContainText("acme/roadmap");
  const permissionsChip = page.getByRole("button", {
    name: "Skip Permissions",
  });
  await expect(permissionsChip).toBeVisible();
  await expect(permissionsChip).toHaveClass(/accent-amber/);
  await expect(page.getByText(/\$\d+ cap/)).toHaveCount(0);
  await permissionsChip.click();
  const approveEditsChip = page.getByRole("button", { name: "Approve Edits" });
  await expect(approveEditsChip).toBeVisible();
  await expect(approveEditsChip).toHaveClass(/accent-blue/);
  await approveEditsChip.click();
  await expect(
    page.getByRole("button", { name: "Skip Permissions" })
  ).toBeVisible();
  const initialModelChip = page.getByRole("button", {
    name: shortModel,
    exact: true,
  });
  await initialModelChip.click();
  await page.getByRole("button", { name: "anthropic/claude-sonnet-5" }).click();
  // No manual plan-mode gate: the composer sends straight to the agent.
  await expect(page.getByRole("button", { name: "Plan mode" })).toHaveCount(0);
  await page
    .locator('input[type="file"]')
    .first()
    .setInputFiles({
      name: "control-plan.txt",
      mimeType: "text/plain",
      buffer: Buffer.from("Prefer the smallest safe release plan."),
    });
  await expect(page.getByText("control-plan.txt")).toBeVisible();
  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("Ship the new onboarding flow");
  await page.getByRole("button", { name: "Start mission" }).click();
  // Streamed replies and tool status render in the conversation.
  const conversation = page.getByRole("log", { name: "Conversation" });
  await expect(
    conversation.getByText("I can plan, delegate, and ship.")
  ).toBeVisible();
  await expect(conversation.getByText("Checking workers")).toBeVisible();
  await conversation.locator("summary").click();
  await expect(
    conversation.locator("pre").filter({ hasText: /list_worktrees\(/ })
  ).toBeVisible();
  await expect(page.getByText(/Mogplex is planning/)).toHaveCount(0);
  await expect(page.getByText(/Budget: \$/)).toHaveCount(0);
  expect(chatRequests[0]).toMatchObject({
    model: "anthropic/claude-sonnet-5",
    mode: "run",
    permissions: "Skip Permissions",
    scope: "IMPLEMENT",
    target: "mission",
    conversationId: "sess-e2e-1",
    repoId: "repo-2",
    repoFullName: "acme/roadmap",
    repoOwner: "acme",
    repoName: "roadmap",
    repoBranch: "main",
    repoBaseBranch: "main",
  });
  await expect(page).toHaveURL(/\/control\?mission=sess-e2e-1$/);
  await expect(
    conversation.getByText("I can plan, delegate, and ship.")
  ).toBeVisible();
  // The new session is tied to the repo's project.
  expect(sessionCreates).toHaveLength(1);
  expect(sessionCreates[0]).toMatchObject({
    project: "acme/roadmap",
    repo_id: "repo-2",
    model_id: "anthropic/claude-sonnet-5",
  });
  expect(chatRequests[0]?.messages?.at(-1)?.parts).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        type: "text",
        text: "Ship the new onboarding flow",
      }),
      expect.objectContaining({
        type: "file",
        filename: "control-plan.txt",
        mediaType: "text/plain",
      }),
    ])
  );
  const initialFilePart = chatRequests[0]?.messages
    ?.at(-1)
    ?.parts?.find((part) => part.type === "file");
  expect(initialFilePart?.url).toContain("data:text/plain");
  // Conversation controls retain the persisted model.
  const composerPermissions = page.getByRole("button", {
    name: "Skip Permissions",
  });
  await expect(composerPermissions).toBeVisible();
  const modelChip = page.getByRole("button", {
    name: "claude-sonnet-5",
    exact: true,
  });
  await expect(modelChip).toBeVisible();
  const mcpButton = page.getByRole("button", { name: "Tools: 0" });
  await expect(mcpButton).toBeVisible();
  await mcpButton.click();
  await expect(page.getByRole("dialog", { name: "MCP servers" })).toBeVisible();
  await page.keyboard.press("Escape");
  // Switching models routes the chosen id through to the chat request body.
  await modelChip.click();
  await page.getByRole("button", { name: modelId }).click();
  await expect
    .poll(() => sessionUpdates.some((update) => update.model_id === modelId))
    .toBe(true);
  await expect(page.getByRole("button", { name: "Plan mode" })).toHaveCount(0);
  await page
    .getByPlaceholder("Ask for follow-up changes or attach images")
    .fill("Summarize progress");
  await page.getByRole("button", { name: "Send" }).click();
  await expect
    .poll(() => chatRequests.at(-1)?.model, { timeout: 10_000 })
    .toBe(modelId);
  expect(chatRequests.at(-1)).toMatchObject({
    mode: "run",
    permissions: "Skip Permissions",
    scope: "IMPLEMENT",
    target: "mission",
  });
  await expect(
    page.getByRole("button", { name: "Attach file" }).last()
  ).toBeEnabled();
  await page.getByTestId("control-composer-dropzone").evaluate((element) => {
    const dataTransfer = new DataTransfer();
    dataTransfer.items.add(
      new File(["image bytes"], "dropped-screenshot.png", {
        type: "image/png",
      })
    );
    element.dispatchEvent(
      new DragEvent("drop", { bubbles: true, dataTransfer })
    );
  });
  await expect(page.getByText("dropped-screenshot.png")).toBeVisible();
  await page.getByRole("button", { name: "Send" }).click();

  await expect.poll(() => chatRequests.length, { timeout: 10_000 }).toBe(3);
  const attachmentOnlyRequest = chatRequests.at(-1);
  expect(attachmentOnlyRequest).toMatchObject({
    mode: "run",
    scope: "IMPLEMENT",
    target: "mission",
  });
  expect(attachmentOnlyRequest?.messages?.at(-1)?.parts).toEqual([
    expect.objectContaining({
      type: "file",
      filename: "dropped-screenshot.png",
      mediaType: "image/png",
    }),
  ]);
});

test("control chat surfaces request failures instead of swallowing them", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  await mockControlSessionBootstrap(page);
  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/chat", (route) =>
    route.fulfill({ status: 500, body: "orchestrator unavailable" })
  );

  await page.goto(scopedPath("control"));
  await page.waitForLoadState("networkidle");

  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("Ship something");
  await page.getByRole("button", { name: "Start mission" }).click();

  // The failed first send must produce a visible error, not silence.
  await expect(
    page.locator(".text-accent-amber").filter({ hasText: /./ }).first()
  ).toBeVisible();
});

test("control timeline renders agent markdown as formatted HTML", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  await mockControlSessionBootstrap(page);
  await page.route("**/api/connections", (route) =>
    fulfillJson(route, { connections: [] })
  );
  // The agent's reply is markdown: a heading plus a GFM table. The timeline
  // must render it as HTML, not literal pipes and dashes.
  const agentMarkdown = [
    "## Tool overview",
    "",
    "| Tool | Purpose |",
    "| --- | --- |",
    "| read_file | Read a file from the repo |",
    "| write_file | Write content to a file |",
  ].join("\n");
  const streamChunks = [
    { type: "start" },
    { type: "text-start", id: "t1" },
    { type: "text-delta", id: "t1", delta: agentMarkdown },
    { type: "text-end", id: "t1" },
    { type: "finish" },
  ];
  const streamBody =
    streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
    "data: [DONE]\n\n";
  await page.route("**/api/control/chat", (route) =>
    route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body: streamBody,
    })
  );

  await page.goto(scopedPath("control"));
  await page.waitForLoadState("networkidle");

  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("List your tools");
  await page.getByRole("button", { name: "Start mission" }).click();

  await expect(
    page.getByRole("heading", { name: "Tool overview" }).first()
  ).toBeVisible();
  await expect(
    page.getByRole("cell", { name: "Read a file from the repo" }).first()
  ).toBeVisible();
  await expect(
    page.getByRole("complementary", { name: "Artifacts" })
  ).toHaveCount(0);
  await expect(
    page.getByRole("log", { name: "Conversation" }).getByText("| --- |")
  ).toHaveCount(0);
});

test("control timeline does not expose raw infrastructure details", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(page);
  await mockControlSessionBootstrap(page);
  await page.route("**/api/connections", (route) =>
    fulfillJson(route, { connections: [] })
  );

  const rawAgentText =
    "The Vercel Sandbox failed at /Users/runner/acme/widgets/.worktrees/sbx_abcdef/src/app.ts for deployment dpl_123456.";
  const streamChunks = [
    { type: "start" },
    { type: "text-start", id: "t1" },
    { type: "text-delta", id: "t1", delta: rawAgentText },
    { type: "text-end", id: "t1" },
    { type: "finish" },
  ];
  const streamBody =
    streamChunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") +
    "data: [DONE]\n\n";
  await page.route("**/api/control/chat", (route) =>
    route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body: streamBody,
    })
  );

  await page.goto(scopedPath("control"));
  await page.waitForLoadState("networkidle");
  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("Ship the fix");
  await page.getByRole("button", { name: "Start mission" }).click();

  const conversation = page.getByRole("log", { name: "Conversation" });
  await expect(conversation).toContainText("development environment failed");
  await expect(conversation).toContainText("src/app.ts");
  await expect(conversation).not.toContainText("Vercel Sandbox");
  await expect(conversation).not.toContainText("/Users/runner");
  await expect(conversation).not.toContainText("dpl_123456");
});