484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { test } from "../../fixtures/test-base";
|
|
import { KanbanPage } from "../../pages/kanban-page";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { seedAvailableCommands } from "../../helpers/session-store";
|
|
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
|
|
|
|
type PersistedAttachment = {
|
|
type?: string;
|
|
name?: string;
|
|
mime_type?: string;
|
|
delivery_mode?: string;
|
|
};
|
|
|
|
/**
|
|
* CLI-mode parity: PassthroughToolbar mounts above the PTY in passthrough
|
|
* sessions and exposes the kandev compose box + Stop button that ACP sessions
|
|
* get via ChatInputArea. These tests verify:
|
|
*
|
|
* 1. The toolbar renders for passthrough sessions.
|
|
* 2. The Chat toggle + send path forwards the typed message to the agent's
|
|
* stdin (mock-agent echoes it as "Processed: <text>").
|
|
* 3. No dedicated Stop button — users cancel via Ctrl-C in the xterm terminal
|
|
* (the toolbar intentionally omits a duplicate control).
|
|
*/
|
|
test.describe("CLI mode: passthrough toolbar", () => {
|
|
/** Create a passthrough agent profile and return its ID. */
|
|
async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise<string> {
|
|
const { agents } = await apiClient.listAgents();
|
|
if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
|
|
const profile = await apiClient.createAgentProfile(agents[0].id, name, {
|
|
model: "mock-fast",
|
|
auto_approve: true,
|
|
cli_passthrough: true,
|
|
});
|
|
return profile.id;
|
|
}
|
|
|
|
/**
|
|
* Navigate to the kanban, click the task card, and wait for the passthrough
|
|
* terminal to finish loading.
|
|
*/
|
|
async function openTaskAndWaitForTerminal(
|
|
testPage: import("@playwright/test").Page,
|
|
kanban: KanbanPage,
|
|
session: SessionPage,
|
|
taskTitle: string,
|
|
) {
|
|
const card = kanban.taskCardByTitle(taskTitle);
|
|
await expect(card).toBeVisible({ timeout: 20_000 });
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
await session.waitForPassthroughLoad(20_000);
|
|
await session.waitForPassthroughLoaded(20_000);
|
|
}
|
|
|
|
function passthroughEditor(testPage: import("@playwright/test").Page) {
|
|
return testPage.getByTestId("passthrough-composer").locator(".tiptap.ProseMirror");
|
|
}
|
|
|
|
function passthroughComposer(testPage: import("@playwright/test").Page) {
|
|
return testPage.getByTestId("passthrough-composer");
|
|
}
|
|
|
|
async function waitForPassthroughPromptable(
|
|
apiClient: ApiClient,
|
|
taskId: string,
|
|
sessionId: string,
|
|
) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(taskId);
|
|
return sessions.find((s) => s.id === sessionId)?.state ?? null;
|
|
},
|
|
{ timeout: 20_000, message: "Wait for passthrough session to accept a follow-up" },
|
|
)
|
|
.toBe("WAITING_FOR_INPUT");
|
|
}
|
|
|
|
async function waitForPersistedAttachment(
|
|
apiClient: ApiClient,
|
|
sessionId: string,
|
|
content: string,
|
|
): Promise<PersistedAttachment> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { messages } = await apiClient.listSessionMessages(sessionId);
|
|
const message = messages.find(
|
|
(m) => m.author_type === "user" && m.content.includes(content),
|
|
);
|
|
const attachments = message?.metadata?.attachments;
|
|
return Array.isArray(attachments) ? attachments.length : 0;
|
|
},
|
|
{ timeout: 15_000, message: `Wait for persisted attachment on "${content}"` },
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
const { messages } = await apiClient.listSessionMessages(sessionId);
|
|
const message = messages.find((m) => m.author_type === "user" && m.content.includes(content));
|
|
const attachments = message?.metadata?.attachments;
|
|
if (!Array.isArray(attachments)) throw new Error("Persisted user message has no attachments");
|
|
return attachments[0] as PersistedAttachment;
|
|
}
|
|
|
|
test("toolbar renders for passthrough sessions on the task page", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Render");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar Render Task", profileId, {
|
|
description: "hello toolbar",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Render Task");
|
|
|
|
await expect(testPage.getByTestId("passthrough-toolbar")).toBeVisible({ timeout: 10_000 });
|
|
await expect(testPage.getByTestId("passthrough-toggle-composer")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
|
|
const chatToggle = testPage.getByTestId("passthrough-toggle-composer");
|
|
await chatToggle.hover();
|
|
const tooltip = testPage.getByRole("tooltip").filter({ hasText: "Shortcut:" });
|
|
await expect(tooltip).toContainText(/Ctrl\+Shift\+Y|Cmd\+Shift\+Y/);
|
|
|
|
await chatToggle.click();
|
|
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
|
|
await expect(testPage.getByTestId("plan-mode-toggle-button")).toBeVisible();
|
|
await expect(testPage.getByTestId("chat-attachments-button")).toBeVisible();
|
|
await expect(testPage.getByTestId("chat-context-button")).toBeVisible();
|
|
await expect(testPage.getByTestId("toolbar-item-mcp")).toHaveCount(0);
|
|
await expect(testPage.getByTestId("toolbar-item-mode")).toHaveCount(0);
|
|
await expect(testPage.getByTestId("toolbar-item-model")).toHaveCount(0);
|
|
await expect(testPage.getByTestId("toolbar-item-reset-context")).toHaveCount(0);
|
|
await expect(testPage.getByTestId("toolbar-item-enhance")).toHaveCount(0);
|
|
|
|
await chatToggle.click();
|
|
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 5_000 });
|
|
});
|
|
|
|
test("composer toggle + send forwards message to the agent", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Send");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar Send Task", profileId, {
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Send Task");
|
|
|
|
// Wait for the initial prompt injection to complete
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
|
|
// Open the composer and send a follow-up message
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
|
|
|
|
const editor = passthroughEditor(testPage);
|
|
await editor.fill("hello from e2e");
|
|
await testPage.getByTestId("submit-message-button").click();
|
|
|
|
// The composer closes on successful send
|
|
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 10_000 });
|
|
|
|
// The mock-agent TUI echoes the prompt as "Processed: <text>"
|
|
await session.expectPassthroughHasText("hello from e2e", 15_000);
|
|
});
|
|
|
|
test("composer keeps slash literal and does not show command suggestions", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Commands");
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Toolbar Commands Task",
|
|
profileId,
|
|
{
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("expected passthrough task to start a session");
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Commands Task");
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
await seedAvailableCommands(testPage, task.session_id, [
|
|
{ name: "slow", description: "Run slowly" },
|
|
{ name: "error", description: "Trigger an error" },
|
|
]);
|
|
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
const editor = passthroughEditor(testPage);
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
|
|
await editor.fill("/s");
|
|
|
|
await expect(testPage.getByRole("listbox", { name: "Command suggestions" })).toHaveCount(0);
|
|
await expect(editor).toHaveText("/s");
|
|
});
|
|
|
|
test("supports @ prompt/file lookup chips and sends context to passthrough stdin", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
|
|
const contextFile = `ctx/passthrough-context-${Date.now()}.ts`;
|
|
git.createFile(contextFile, "export const passthroughContextMarker = true;\n");
|
|
git.stageAll();
|
|
git.commit("seed passthrough context lookup");
|
|
|
|
const promptName = `pt-prompt-${Date.now()}`;
|
|
const promptContent = "PASSTHROUGH_PROMPT_CONTEXT_MARKER";
|
|
await apiClient.createPrompt(promptName, promptContent);
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Context");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar Context Task", profileId, {
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Context Task");
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
const editor = passthroughEditor(testPage);
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
|
|
await editor.click();
|
|
await editor.pressSequentially(`context lookup e2e @${promptName}`);
|
|
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await testPage.getByRole("button", { name: new RegExp(promptName) }).click();
|
|
await expect(editor).toContainText(promptName);
|
|
|
|
const contextFileName = path.basename(contextFile);
|
|
await editor.pressSequentially(` @${contextFileName}`);
|
|
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await testPage.getByRole("button", { name: new RegExp(contextFileName) }).click();
|
|
await expect(editor).toContainText(contextFileName);
|
|
|
|
await testPage.getByTestId("submit-message-button").click();
|
|
await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
|
|
|
|
await session.expectPassthroughHasText("context lookup e2e", 15_000);
|
|
await session.expectPassthroughHasText("CONTEXT PROMPTS", 15_000);
|
|
await session.expectPassthroughHasText(promptContent, 15_000);
|
|
await session.expectPassthroughHasText("CONTEXT FILES", 15_000);
|
|
await session.expectPassthroughHasText(contextFile, 15_000);
|
|
});
|
|
|
|
test("expands @ Plan context into passthrough stdin instead of sending a literal mention", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Plan Context");
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Toolbar Plan Context Task",
|
|
profileId,
|
|
{
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("expected passthrough task to start a session");
|
|
|
|
const planContent = "PASSTHROUGH_PLAN_CONTEXT_MARKER";
|
|
await apiClient.wsRequest("task.plan.create", {
|
|
task_id: task.id,
|
|
title: "Plan",
|
|
content: `## Plan\n\n${planContent}`,
|
|
created_by: "user",
|
|
});
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Plan Context Task");
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
const editor = passthroughEditor(testPage);
|
|
await expect(editor).toBeVisible({ timeout: 5_000 });
|
|
|
|
await editor.click();
|
|
await editor.pressSequentially("@Plan");
|
|
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
await testPage.getByRole("button", { name: /^Plan Include the plan as context$/ }).click();
|
|
await expect(editor).toContainText("Plan");
|
|
|
|
await testPage.getByTestId("submit-message-button").click();
|
|
await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
|
|
|
|
await session.expectPassthroughHasText("CONTEXT PLAN", 15_000);
|
|
await session.expectPassthroughHasText(planContent, 15_000);
|
|
});
|
|
|
|
test("uploads attachments and delivers them as workspace file paths", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}, testInfo) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Attachment");
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Toolbar Attachment Task",
|
|
profileId,
|
|
{
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("expected passthrough task to start a session");
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Attachment Task");
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
|
|
|
|
fs.mkdirSync(testInfo.outputDir, { recursive: true });
|
|
const attachmentPath = path.join(testInfo.outputDir, "passthrough-upload.txt");
|
|
fs.writeFileSync(attachmentPath, "passthrough attachment body");
|
|
|
|
await testPage.getByTestId("passthrough-toggle-composer").click();
|
|
const composer = passthroughComposer(testPage);
|
|
await composer.locator('input[type="file"]').setInputFiles(attachmentPath);
|
|
await expect(composer.getByText("passthrough-upload.txt", { exact: true })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await passthroughEditor(testPage).fill("read the attached file");
|
|
await testPage.getByTestId("submit-message-button").click();
|
|
await expect(composer).toBeHidden({ timeout: 10_000 });
|
|
|
|
await session.expectPassthroughHasText("read the attached file", 15_000);
|
|
await session.expectPassthroughHasText("passthrough-upload.txt", 15_000);
|
|
const attachment = await waitForPersistedAttachment(
|
|
apiClient,
|
|
task.session_id,
|
|
"read the attached file",
|
|
);
|
|
expect(attachment).toMatchObject({
|
|
type: "resource",
|
|
name: "passthrough-upload.txt",
|
|
mime_type: "text/plain",
|
|
delivery_mode: "path",
|
|
});
|
|
});
|
|
|
|
test("slash stays in the terminal while the dedicated shortcut focuses the composer", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Focus Shortcut");
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Toolbar Focus Shortcut Task",
|
|
profileId,
|
|
{
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("expected passthrough task to start a session");
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Focus Shortcut Task");
|
|
await session.expectPassthroughHasText("Processed:", 20_000);
|
|
await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
|
|
|
|
await session.passthroughTerminal.locator(".xterm").click();
|
|
await testPage.keyboard.type("/terminal-slash-e2e");
|
|
await expect(passthroughComposer(testPage)).toHaveCount(0);
|
|
await session.expectPassthroughHasText("/terminal-slash-e2e", 5_000);
|
|
|
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
|
await testPage.keyboard.press(`${modifier}+Shift+y`);
|
|
|
|
const composer = passthroughComposer(testPage);
|
|
await expect(composer).toBeVisible({ timeout: 5_000 });
|
|
await expect(passthroughEditor(testPage)).toBeFocused();
|
|
});
|
|
|
|
test("toolbar omits a Stop button; cancel is via Ctrl-C in the terminal", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar No Stop");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar No Stop Task", profileId, {
|
|
description: "initial prompt",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const session = new SessionPage(testPage);
|
|
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar No Stop Task");
|
|
|
|
await expect(testPage.getByTestId("passthrough-toolbar")).toBeVisible({ timeout: 10_000 });
|
|
// PassthroughToolbar deliberately has no Stop affordance — Ctrl-C in xterm is the path.
|
|
await expect(testPage.getByTestId("passthrough-stop")).toHaveCount(0);
|
|
|
|
await session.passthroughTerminal.locator(".xterm").click();
|
|
await testPage.keyboard.press("Control+c");
|
|
// Terminal stays mounted; we only assert the UI never offered a misleading Stop button.
|
|
await expect(testPage.getByTestId("passthrough-terminal")).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
});
|