Files
AgentCompany/tests/e2e/ceo-chat.spec.ts
T

248 lines
15 KiB
TypeScript

import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
/**
* The founder's direct line to the CEO: onboarding leaves a pinned CEO
* conversation at the top of Messages, the page opens on it, a founder
* message lands there and queues a CEO wake run, and no amount of channel
* recency displaces the pin.
*/
test("the pinned CEO conversation is the Messages front door", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-ceo-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-ceo-builtin-${randomUUID()}`));
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: {
...process.env,
AGENTCOMPANY_NEW_PROFILE_DIR: profileDir,
AGENTCOMPANY_NEW_AUTORUN: "0",
AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
await page.getByRole("button", { name: "Get started" }).click();
await page.getByRole("button", { name: /^OpenAI API key/ }).click();
await page.getByLabel("API key").fill("e2e-secret");
await page.getByLabel("Model").fill("gpt-5.4");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Company name").fill("FlowPilot");
await page.getByLabel("Your name").fill("Yang");
await page.getByLabel("Description").fill("A CSM analytics product for SMBs.");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Goal").fill("Ship the FlowPilot MVP.");
await page.getByLabel("Acceptance criteria").fill("A working product, not documents about one.");
await page.getByRole("button", { name: "Launch" }).click();
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
await page.getByRole("button", { name: "Finish" }).click();
// The CEO conversation is the first row, badged, and already selected —
// opening Messages IS opening the CEO chat.
await page.getByRole("button", { name: "Messages" }).click();
const firstRow = page.locator(".convo-item").first();
await expect(firstRow).toContainText("CEO");
await expect(firstRow.locator(".convo-ceo-badge")).toBeVisible();
await expect(firstRow).toHaveClass(/active/);
await expect(firstRow).toContainText("Ask about progress, goals, or direction");
// A founder message lands in the conversation and queues the CEO's wake
// run — the reply pipeline is armed even with autonomous execution off.
await page.getByRole("textbox", { name: "Message" }).fill("How are we tracking against the goal?");
await page.getByRole("button", { name: "Send" }).click();
const chatLog = page.getByRole("log", { name: "Conversation messages" });
await expect(chatLog.getByText("How are we tracking against the goal?")).toBeVisible();
const wake = await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: {
agents: Array<{ id: string; roleLevel: string }>;
taskRuns: Array<{ agentId: string | null; trigger: string; state: string }>;
} }> } }).agentCompany;
const snapshot = await api.loadSnapshot();
const ceo = snapshot.data.agents.find((agent) => agent.roleLevel === "c_suite")!;
return snapshot.data.taskRuns.filter((run) => run.agentId === ceo.id && run.trigger === "chat_mention").map((run) => run.state);
});
expect(wake).toEqual(["queued"]);
// The message itself says where the reply stands.
await expect(chatLog.locator(".chat-message", { hasText: "How are we tracking against the goal?" }).first()).toContainText("CEO will reply; queued.");
// From a task, its goal, run and back are one click each.
await page.getByRole("button", { name: "Work" }).click();
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
const taskDialog = page.getByRole("dialog", { name: "Operationalize goal: Ship the FlowPilot MVP." });
await taskDialog.getByRole("button", { name: "Open goal Ship the FlowPilot MVP." }).click();
await expect(page.getByText("Goal: Ship the FlowPilot MVP.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
await taskDialog.getByRole("button", { name: /^Open run/ }).first().click();
await expect(page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." })).toBeVisible();
await page.getByRole("button", { name: "Open task Operationalize goal: Ship the FlowPilot MVP." }).click();
await expect(taskDialog).toBeVisible();
// A wrong link is corrected in the same task's editor, as at creation.
await taskDialog.getByRole("button", { name: "Edit" }).click();
await taskDialog.getByLabel("Writing").selectOption("technical");
await taskDialog.getByLabel("Requires workspace access").check();
await taskDialog.getByRole("button", { name: "Save" }).click();
await expect(taskDialog.getByRole("heading", { name: "Edit task" })).toHaveCount(0);
const edited = await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { tasks: Array<{ title: string; writingMode: string; requiresFilesystem: boolean }> } }> } }).agentCompany;
const task = (await api.loadSnapshot()).data.tasks.find((item) => item.title === "Operationalize goal: Ship the FlowPilot MVP.")!;
return [task.writingMode, task.requiresFilesystem];
});
expect(edited).toEqual(["technical", true]);
await taskDialog.getByRole("button", { name: "Close" }).click();
await page.getByRole("button", { name: "Messages" }).click();
// Recency pressure: newer traffic in a channel must not displace the pin.
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: {
activeOrgId: string;
employees: Array<{ id: string; orgId: string }>;
chatSpaces: Array<{ id: string; orgId: string; name: string }>;
} }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = await api.loadSnapshot();
const orgId = snapshot.data.activeOrgId;
const employee = snapshot.data.employees.find((item) => item.orgId === orgId)!;
const general = snapshot.data.chatSpaces.find((item) => item.orgId === orgId && item.name === "general")!;
await api.sendMessage({
orgId,
spaceId: general.id,
threadId: null,
senderType: "employee",
senderId: employee.id,
bodyMd: "Channel chatter arriving after the founder's CEO message.",
attachmentFileIds: [],
});
});
await expect(page.locator(".convo-item").first().locator(".convo-ceo-badge")).toBeVisible();
await expect(page.locator(".convo-item").first()).toContainText("How are we tracking against the goal?");
// A send the backend refuses keeps the draft and says why, instead of clearing it.
const composer = page.getByRole("textbox", { name: "Message" });
await composer.fill("/nosuch keep this draft");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByRole("alert")).toContainText("Not sent: Slash command /nosuch is unavailable.");
await expect(composer).toHaveValue("/nosuch keep this draft");
await composer.fill("Second thought: what is blocked?");
await page.getByRole("button", { name: "Send" }).click();
await expect(chatLog.getByText("Second thought: what is blocked?")).toBeVisible();
await expect(composer).toHaveValue("");
await expect(page.getByRole("alert")).toHaveCount(0);
// An edit the backend refuses keeps the editor open with the text and says why.
const own = chatLog.locator(".chat-message", { hasText: "Second thought: what is blocked?" });
await own.getByRole("button", { name: "Edit message" }).click();
const editor = own.getByRole("textbox", { name: "Edit message" });
await editor.fill("/status everything");
await own.getByRole("button", { name: "Save" }).click();
await expect(own.getByRole("alert")).toContainText("Not saved: Slash command messages are immutable.");
await expect(editor).toHaveValue("/status everything");
await editor.fill("Second thought: what is blocked right now?");
await own.getByRole("button", { name: "Save" }).click();
await expect(chatLog.getByText("Second thought: what is blocked right now?")).toBeVisible();
await expect(chatLog.getByRole("textbox", { name: "Edit message" })).toHaveCount(0);
// Each conversation keeps its own unsent draft.
await composer.fill("Draft for the CEO only");
await page.locator(".convo-item", { hasText: "general" }).click();
await expect(composer).toHaveValue("");
await composer.fill("Draft for general");
await page.locator(".convo-item").first().click();
await expect(composer).toHaveValue("Draft for the CEO only");
await page.locator(".convo-item", { hasText: "general" }).click();
await expect(composer).toHaveValue("Draft for general");
// A reply goes into the thread of the message it answers, and the timeline says what it answers.
await page.locator(".convo-item").first().click();
const root = chatLog.locator(".chat-message", { hasText: "How are we tracking against the goal?" }).first();
await root.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.getByRole("complementary", { name: "Thread" });
await expect(thread).toContainText("How are we tracking against the goal?");
await thread.getByRole("textbox", { name: "Reply" }).fill("Also: which task is late?");
await thread.getByRole("button", { name: "Send reply" }).click();
await expect(thread.locator(".chat-message", { hasText: "Also: which task is late?" })).toBeVisible();
const reply = chatLog.locator(".chat-message", { hasText: "Also: which task is late?" });
await expect(reply.getByRole("button", { name: /^Replying to Yang/ })).toContainText("How are we tracking against the goal?");
await expect(root).toContainText("1 reply");
await thread.getByRole("button", { name: "Close thread" }).click();
await expect(thread).toHaveCount(0);
// A message that arrives while the reader is up in history stays unread until they scroll down to it.
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; employees: Array<{ id: string; orgId: string }>; chatSpaces: Array<{ id: string; orgId: string; spaceType: string }> } }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = (await api.loadSnapshot()).data;
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId)!;
const ceoChat = snapshot.chatSpaces.find((item) => item.orgId === snapshot.activeOrgId && item.spaceType === "direct")!;
for (let index = 0; index < 40; index += 1) {
await api.sendMessage({ orgId: snapshot.activeOrgId, spaceId: ceoChat.id, threadId: null, senderType: "employee", senderId: employee.id, bodyMd: `History line ${index}`, attachmentFileIds: [] });
}
});
await expect(chatLog.getByText("History line 39")).toBeVisible();
await chatLog.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; agents: Array<{ id: string; orgId: string; roleLevel: string }>; chatSpaces: Array<{ id: string; orgId: string; spaceType: string }> } }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = (await api.loadSnapshot()).data;
const ceo = snapshot.agents.find((agent) => agent.orgId === snapshot.activeOrgId && agent.roleLevel === "c_suite")!;
const ceoChat = snapshot.chatSpaces.find((item) => item.orgId === snapshot.activeOrgId && item.spaceType === "direct")!;
await api.sendMessage({ orgId: snapshot.activeOrgId, spaceId: ceoChat.id, threadId: null, senderType: "agent", senderId: ceo.id, bodyMd: "An update you have not seen yet.", attachmentFileIds: [] });
});
const ceoRow = page.locator(".convo-item").first();
await expect(ceoRow.locator(".nav-badge")).toHaveText("1");
await page.getByRole("button", { name: "Jump to latest" }).click();
await expect(ceoRow.locator(".nav-badge")).toHaveCount(0);
// History older than the loaded window comes from the database, back to the very first message.
await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: {
loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string; employees: Array<{ id: string; orgId: string }>; chatSpaces: Array<{ id: string; orgId: string; name: string }> } }>;
sendMessage: (input: unknown) => Promise<unknown>;
} }).agentCompany;
const snapshot = (await api.loadSnapshot()).data;
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId)!;
const general = snapshot.chatSpaces.find((item) => item.orgId === snapshot.activeOrgId && item.name === "general")!;
for (let index = 0; index < 520; index += 1) {
await api.sendMessage({ orgId: snapshot.activeOrgId, spaceId: general.id, threadId: null, senderType: "employee", senderId: employee.id, bodyMd: `General line ${index}`, attachmentFileIds: [] });
}
});
await page.locator(".convo-item", { hasText: "general" }).click();
await expect(chatLog.getByText("General line 519")).toBeVisible();
const start = chatLog.getByText("This is the start of the conversation.");
for (let click = 0; click < 10 && !(await start.isVisible()); click += 1) {
await chatLog.getByRole("button", { name: "Show earlier messages" }).click();
await page.waitForTimeout(300);
}
await expect(start).toBeVisible();
await expect(chatLog.getByText("General line 0", { exact: true })).toBeVisible();
// The header lists the real members, how each agent replies, and who can read.
await page.getByRole("button", { name: /^Members \(/ }).click();
const roster = page.getByRole("menu");
await expect(roster).toContainText("Everyone in the company can read this channel.");
await expect(roster.locator(".members-roster")).toHaveCSS("overflow-y", "auto");
await expect(roster).toContainText("Replies when @mentioned, in threads it joined, and to messages that name no one");
await page.keyboard.press("Escape");
await page.locator(".convo-item").first().click();
await page.getByRole("button", { name: /^Members \(/ }).click();
await expect(page.getByRole("menu")).toContainText("Replies to every message");
} finally {
await app.close();
}
});