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((resolve) => { releaseTerminal = resolve; }); let reportTerminalRequest!: () => void; const terminalRequested = new Promise((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(); });