From c7eafb2836950a1116b42034af78b0e690b44fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 14 Jul 2026 00:23:49 -0400 Subject: [PATCH] Founder-CEO chat round trip proven end to end; agents see the founder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadChatContext labels human senders "[Name (founder)]" so a woken agent knows it answers its boss, not a teammate. New integration test drives the whole loop — founder DM wakes the CEO, the prompt names the founder, the reply lands in the same pinned conversation and thread, the wake task closes — and a Playwright spec pins the UI contract: badged CEO row first, selected by default, message queues the wake run, channel recency never displaces the pin. AGENTS.md documents the feature contracts. --- src/main/services/task-execution-service.ts | 6 +- tests/e2e/ceo-chat.spec.ts | 102 ++++++++++++++++ tests/integration/ceo-founder-chat.test.ts | 126 ++++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/ceo-chat.spec.ts create mode 100644 tests/integration/ceo-founder-chat.test.ts diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index e22a4080..023a1328 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -490,9 +490,13 @@ export class TaskExecutionService { limit 20 `).all(scope.sourceThreadId, orgId) as Array<{ body_md: string; sender_type: string; sender_name: string }>; if (messages.length === 0) return null; + // Human principals are marked as the founder: an agent replying to its + // boss must know it is not talking to another agent. + const label = (m: { sender_type: string; sender_name: string }) => + m.sender_type === "employee" || m.sender_type === "board" ? `${m.sender_name} (founder)` : m.sender_name; return { threadId: scope.sourceThreadId, - transcript: messages.map((m) => `[${m.sender_name}]: ${m.body_md}`).join("\n"), + transcript: messages.map((m) => `[${label(m)}]: ${m.body_md}`).join("\n"), }; } diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts new file mode 100644 index 00000000..d2c883e5 --- /dev/null +++ b/tests/e2e/ceo-chat.spec.ts @@ -0,0 +1,102 @@ +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"]); + + // 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; + } }).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?"); + } finally { + await app.close(); + } +}); diff --git a/tests/integration/ceo-founder-chat.test.ts b/tests/integration/ceo-founder-chat.test.ts new file mode 100644 index 00000000..ed7ca0b3 --- /dev/null +++ b/tests/integration/ceo-founder-chat.test.ts @@ -0,0 +1,126 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OnboardingService } from "../../src/main/services/onboarding-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { TaskExecutionService } from "../../src/main/services/task-execution-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { ensureCeoDirectSpace } from "../../src/main/services/ceo-direct-space"; +import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker"; +import type { AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn"; + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +function call(name: string, args: Record): ToolCall { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +/** + * Scripts the CEO: the kickoff run ends quietly; the chat-wake run (its + * prompt carries the thread transcript) is captured and answered with a + * direct reply to the founder in the same thread. + */ +function scriptedCeo(capturedWakePrompts: string[]): (input: ProviderToolTurnInput) => Promise { + let replied = false; + return async (input: ProviderToolTurnInput): Promise => { + const firstUser = input.messages.find((message) => message.role === "user"); + const prompt = firstUser?.text ?? ""; + if (!/thread_id="/.test(prompt)) { + return { text: "Kickoff noted.", toolCalls: [], rawResponseJson: "{}" }; + } + if (replied) return { text: "Replied.", toolCalls: [], rawResponseJson: "{}" }; + replied = true; + capturedWakePrompts.push(prompt); + const threadId = /thread_id="([^"]+)"/.exec(prompt)![1]; + return { + text: "Answering the founder.", + toolCalls: [call("send_message", { to: "Yang", body: "We are on track — the kickoff wave is planned.", thread_id: threadId })], + rawResponseJson: "{}", + }; + }; +} + +describe("founder⇄CEO chat", () => { + test("a founder DM wakes the CEO, who sees who is asking and replies in the pinned conversation", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-chat-${randomUUID()}.db`)); + const capturedWakePrompts: string[] = []; + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, scriptedCeo(capturedWakePrompts)); + const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); + const taskExecution = new TaskExecutionService(connection.db, providers); + const chat = new ChatService(connection.db); + + let orgId = ""; + let ceoId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "FlowPilot", + companyDescription: "CSM analytics for SMBs.", + employeeName: "Yang", + workspacePath: "", + executionMode: "ai_ceo", + autoApproveHires: true, + reviewDeliverables: false, + goalTitle: "Ship the MVP.", + goalDescription: "A working product.", + providerKey: "openai", + providerSecret: "sk-test-key", + providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + ceoId = summary.ceoAgentId; + }); + const founder = connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }; + + // Onboarding already created the pinned conversation; ensure only finds it. + const pinnedSpaceId = ensureCeoDirectSpace(connection.db, orgId); + expect(pinnedSpaceId).toBeTruthy(); + + // Drain the kickoff run so the next queued run is the chat wake. + const kickoff = claimNextQueuedRun(connection); + await taskExecution.executeRun(kickoff!.org_id, kickoff!.id); + + const founderMessageId = chat.sendMessage({ + orgId, + spaceId: pinnedSpaceId!, + threadId: null, + senderType: "employee", + senderId: founder.id, + bodyMd: "How are we tracking against the MVP?", + attachmentFileIds: [], + }); + const founderThreadId = (connection.db.prepare("select thread_id from chat_messages where id = ?") + .get(founderMessageId) as { thread_id: string }).thread_id; + + const wake = connection.db.prepare( + "select id from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention' and state = 'queued'", + ).get(orgId, ceoId) as { id: string }; + await taskExecution.executeRun(orgId, wake.id); + + // The CEO's prompt names the human principal, not just another teammate. + expect(capturedWakePrompts).toHaveLength(1); + expect(capturedWakePrompts[0]).toContain("[Yang (founder)]: How are we tracking against the MVP?"); + + // The reply landed in the SAME pinned conversation and thread — to="Yang" + // resolves the employee and ensureDirectSpace reuses the founder's line. + const reply = connection.db.prepare( + "select space_id, thread_id, body_md from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ?", + ).get(orgId, ceoId) as { space_id: string; thread_id: string; body_md: string }; + expect(reply.space_id).toBe(pinnedSpaceId); + expect(reply.thread_id).toBe(founderThreadId); + expect(reply.body_md).toContain("on track"); + + // The reply was the wake task's deliverable; completing the run closed it. + const wakeTask = connection.db.prepare( + "select t.status from tasks t join task_runs r on r.task_id = t.id where r.id = ?", + ).get(wake.id) as { status: string }; + expect(wakeTask).toEqual({ status: "done" }); + + connection.close(); + }); +});