Ctrl K
ComunidadGuías…

tests/e2e/control-composer-failure.spec.ts

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

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

test("control composer keeps text and attachments when a follow-up send fails", async ({
  page,
}) => {
  await enableScopedE2EAuth(page);
  await mockBaseChrome(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",
      },
    ])
  );
  const session = {
    id: "session-retry",
    title: "Retry failed follow-up",
    project: "acme/widgets",
    repo_id: "repo-1",
    orchestration_run_id: "run-retry",
    pinned: false,
    archived: false,
    messages: [],
    created_at: "2026-08-13T00:00:00.000Z",
    updated_at: "2026-08-13T00:00:00.000Z",
  };
  await page.route("**/api/control/sessions**", (route) => {
    const id = new URL(route.request().url()).searchParams.get("id");
    return fulfillJson(route, id ? session : [session]);
  });
  await page.route("**/api/control/worktrees**", (route) =>
    fulfillJson(route, { worktrees: [] })
  );
  await page.route("**/api/sandbox", (route) =>
    fulfillJson(route, { sandboxes: [] })
  );
  let releaseRequest: () => void;
  const responseAllowed = new Promise<void>((resolve) => {
    releaseRequest = resolve;
  });
  let requestStarted: () => void;
  const requestReceived = new Promise<void>((resolve) => {
    requestStarted = resolve;
  });
  await page.route("**/api/control/chat", async (route) => {
    requestStarted!();
    await responseAllowed;
    await route.fulfill({ status: 500, body: "orchestrator unavailable" });
  });

  await page.goto(`${scopedPath("control")}?mission=session-retry`);
  const composer = page.getByPlaceholder(
    "Ask for follow-up changes or attach images"
  );
  await expect(composer).toBeVisible();
  await composer.fill("Retry this exact request");
  await page
    .locator('input[type="file"]')
    .last()
    .setInputFiles({
      name: "retry-context.txt",
      mimeType: "text/plain",
      buffer: Buffer.from("do not discard this context"),
    });
  await expect(page.getByText("retry-context.txt")).toBeVisible();
  await page.getByRole("button", { name: "Send" }).click();
  await requestReceived;

  // A submitted draft should leave the composer immediately, rather than
  // waiting for the server to finish accepting or rejecting the request.
  await expect(composer).toHaveValue("");
  releaseRequest!();

  await expect(
    page.locator(".text-accent-amber").filter({ hasText: /./ }).first()
  ).toBeVisible();
  await expect(composer).toHaveValue("Retry this exact request");
  await expect(page.getByText("retry-context.txt")).toBeVisible();
});

for (const reload of [false, true])
  test(`a failed first message ${reload ? "survives reload" : "retains its optimistic turn"} and Retry sends it with its attachments once`, async ({
    page,
  }) => {
    await mockRecoveryChrome(page);
    let created = false;
    const stored = {
      ...recoverySession,
      id: "first-message-session",
      messages: [] as unknown[],
    };
    await page.route("**/api/control/sessions**", (route) => {
      const method = route.request().method();
      if (method === "POST") {
        created = true;
        return fulfillJson(route, stored);
      }
      if (method === "PUT") {
        stored.messages = route.request().postDataJSON().messages;
        return fulfillJson(route, { ok: true, session: stored });
      }
      return fulfillJson(
        route,
        new URL(route.request().url()).searchParams.has("id")
          ? stored
          : created
            ? [stored]
            : []
      );
    });
    let blocked = true;
    const requests: Array<Record<string, unknown>> = [];
    await page.route("**/api/control/chat", (route) => {
      requests.push(route.request().postDataJSON());
      if (blocked) return route.abort("failed");
      return route.fulfill({
        status: 200,
        headers: {
          "content-type": "text/event-stream",
          "x-vercel-ai-ui-message-stream": "v1",
        },
        body: recoveryStream(),
      });
    });
    await page.goto(scopedPath("control"));
    await page
      .getByPlaceholder("Ask anything or run a command...")
      .fill("Keep my first request");
    await page
      .locator('input[type="file"]')
      .last()
      .setInputFiles({
        name: "first-context.txt",
        mimeType: "text/plain",
        buffer: Buffer.alloc(4 * 1024 * 1024, 65),
      });
    await page.getByRole("button", { name: "Start mission" }).click();
    await expect(
      page
        .getByRole("alert")
        .filter({ hasText: "Your first message was not sent" })
    ).toContainText("Your first message was not sent");
    const savedDraft = await page.evaluate(() =>
      sessionStorage.getItem(
        "mogplex.control.pendingInitial.first-message-session"
      )
    );
    expect(savedDraft).not.toBeNull();
    expect(savedDraft!.length).toBeLessThan(2_000);
    await (reload
      ? page.reload()
      : expect(
          page.getByText("Keep my first request", { exact: true })
        ).toHaveCount(1));
    await expect(
      page
        .getByRole("alert")
        .filter({ hasText: "Your first message was not sent" })
    ).toContainText("Your first message was not sent");
    expect(requests.length).toBe(1);
    blocked = false;
    const before = requests.length;
    await page
      .getByRole("alert")
      .filter({ hasText: "Your first message was not sent" })
      .getByRole("button", { name: "Retry" })
      .click();
    await expect(
      page.getByText("Request recovered.", { exact: true })
    ).toBeVisible();
    await expect(
      page.getByText("Keep my first request", { exact: true })
    ).toHaveCount(1);
    expect(requests.length).toBe(before + 1);
    const sentMessages = requests.at(-1)?.messages as Array<{
      role: string;
      parts: Array<{ filename?: string }>;
    }>;
    expect(
      sentMessages.filter((message) => message.role === "user")
    ).toHaveLength(1);
    expect(
      sentMessages
        .flatMap((message) => message.parts)
        .some((part) => part.filename === "first-context.txt")
    ).toBe(true);
    expect(
      await page.evaluate(() =>
        sessionStorage.getItem(
          "mogplex.control.pendingInitial.first-message-session"
        )
      )
    ).toBeNull();
  });

test("a first response interrupted after assistant output is not offered as an unsent draft", async ({
  page,
}) => {
  await mockRecoveryChrome(page);
  const stored = {
    ...recoverySession,
    id: "partial-response",
    messages: [] as unknown[],
  };
  await page.route("**/api/control/sessions**", (route) => {
    if (route.request().method() === "POST") return fulfillJson(route, stored);
    if (route.request().method() === "PUT") {
      stored.messages = route.request().postDataJSON().messages ?? [];
      return fulfillJson(route, { session: stored });
    }
    const id = new URL(route.request().url()).searchParams.get("id");
    return fulfillJson(route, id ? stored : []);
  });
  let requests = 0;
  await page.route("**/api/control/chat", (route) => {
    requests++;
    return route.fulfill({
      status: 200,
      headers: {
        "content-type": "text/event-stream",
        "x-vercel-ai-ui-message-stream": "v1",
      },
      body:
        [
          { type: "start" },
          { type: "text-start", id: "partial" },
          {
            type: "text-delta",
            id: "partial",
            delta: "Partial answer received.",
          },
          { type: "error", errorText: "Response interrupted" },
        ]
          .map((part) => `data: ${JSON.stringify(part)}\n\n`)
          .join("") + "data: [DONE]\n\n",
    });
  });
  await page.goto(scopedPath("control"));
  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("Deliver this request once");
  await page
    .getByRole("button", { name: "Start mission", exact: true })
    .click();
  await expect(
    page.getByText("Partial answer received.", { exact: true })
  ).toBeVisible();
  await expect(
    page.getByRole("alert").filter({ hasText: "Response interrupted" })
  ).toBeVisible();
  await expect(
    page.getByText("Your first message was not sent", { exact: true })
  ).toHaveCount(0);
  await expect(
    page.getByRole("button", { name: "Retry", exact: true })
  ).toHaveCount(0);
  await expect(
    page.getByText("Deliver this request once", { exact: true })
  ).toHaveCount(1);
  expect(
    await page.evaluate(() =>
      sessionStorage.getItem("mogplex.control.pendingInitial.partial-response")
    )
  ).toBeNull();
  expect(requests).toBe(1);
});

test("first-message Retry sends from this tab when attachment recovery storage is unavailable", async ({
  page,
}) => {
  await mockRecoveryChrome(page);
  await page.addInitScript(() =>
    Object.defineProperty(window, "indexedDB", {
      get: () => {
        throw new DOMException("Storage blocked", "SecurityError");
      },
    })
  );
  const stored = {
    ...recoverySession,
    id: "blocked-recovery",
    messages: [] as unknown[],
  };
  await page.route("**/api/control/sessions**", (route) => {
    if (route.request().method() === "POST") return fulfillJson(route, stored);
    if (route.request().method() === "PUT") {
      stored.messages = route.request().postDataJSON().messages ?? [];
      return fulfillJson(route, { session: stored });
    }
    const id = new URL(route.request().url()).searchParams.get("id");
    return fulfillJson(route, id ? stored : []);
  });
  const requests: Array<{
    messages: Array<{ role: string; parts: Array<{ filename?: string }> }>;
  }> = [];
  await page.route("**/api/control/chat", (route) => {
    requests.push(route.request().postDataJSON());
    return route.fulfill(
      requests.length === 1
        ? { status: 503, body: "Unavailable" }
        : {
            status: 200,
            headers: {
              "content-type": "text/event-stream",
              "x-vercel-ai-ui-message-stream": "v1",
            },
            body: recoveryStream(),
          }
    );
  });
  await page.goto(scopedPath("control"));
  await page
    .getByPlaceholder("Ask anything or run a command...")
    .fill("Keep my text and file");
  await page
    .locator('input[type="file"]')
    .last()
    .setInputFiles({
      name: "blocked-context.txt",
      mimeType: "text/plain",
      buffer: Buffer.from("Keep this context"),
    });
  await page
    .getByRole("button", { name: "Start mission", exact: true })
    .click();
  const banner = page
    .getByRole("alert")
    .filter({ hasText: "We could not save this draft" });
  await expect(banner).toBeVisible();
  expect(requests).toHaveLength(1);
  await banner.getByRole("button", { name: "Retry", exact: true }).click();
  await expect(
    page.getByText("Request recovered.", { exact: true })
  ).toBeVisible();
  expect(requests).toHaveLength(2);
  const users = requests[1].messages.filter(
    (message) => message.role === "user"
  );
  expect(users).toHaveLength(1);
  expect(
    users[0].parts.some((part) => part.filename === "blocked-context.txt")
  ).toBe(true);
});