Founder-CEO chat round trip proven end to end; agents see the founder
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.
This commit is contained in:
1 parent
3c44ff959d
commit
c7eafb2836
3 files changed
+233
-1
No files matched your search
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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?");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown>): 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<AgentTurnResult> {
|
||||
let replied = false;
|
||||
return async (input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user