A wake run whose model answered in plain final text (the coding-tuned Kimi family does this instead of calling send_message) completed 'successfully', closed its vehicle task, and left the founder staring at a silent conversation — the reply lived only in provider_requests. deliverChatReplyIfMissing now runs before a chat-wake run may complete: when the run never posted into the source thread (run-start watermark, so a send_message it did make suppresses the fallback), its final text is delivered as the agent's reply. A delivery failure fails the run loudly instead of closing it on silence.
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
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 a CEO who never calls send_message: the chat-wake run is answered
|
|
* with plain final text only — the way a direct-answering model (e.g. a
|
|
* coding-tuned one) responds when it treats the wake as a question, not a
|
|
* conversation to post into.
|
|
*/
|
|
function plainTextCeo(): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
|
|
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: "{}" };
|
|
}
|
|
return { text: "We are on track — the kickoff wave is planned.", toolCalls: [], rawResponseJson: "{}" };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
|
|
test("a CEO who answers in plain text still has the reply delivered into the founder's thread", async () => {
|
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-plain-${randomUUID()}.db`));
|
|
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, plainTextCeo());
|
|
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 };
|
|
const pinnedSpaceId = ensureCeoDirectSpace(connection.db, orgId);
|
|
|
|
// 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 model answered in its final text and never called send_message; the
|
|
// reply must still land in the founder's thread — exactly once.
|
|
const replies = connection.db.prepare(
|
|
"select space_id, thread_id, body_md from chat_messages where org_id = ? and sender_type = 'agent' and sender_id = ?",
|
|
).all(orgId, ceoId) as Array<{ space_id: string; thread_id: string; body_md: string }>;
|
|
expect(replies).toHaveLength(1);
|
|
expect(replies[0].space_id).toBe(pinnedSpaceId);
|
|
expect(replies[0].thread_id).toBe(founderThreadId);
|
|
expect(replies[0].body_md).toContain("on track");
|
|
|
|
// The run completed and closed its wake task, not failed on a delivery it made.
|
|
const wakeRun = connection.db.prepare("select state, error_message from task_runs where id = ?")
|
|
.get(wake.id) as { state: string; error_message: string | null };
|
|
expect(wakeRun).toMatchObject({ state: "completed", error_message: null });
|
|
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();
|
|
});
|
|
});
|