A chat-wake reply is delivered by the system, not by prompt compliance
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.
This commit is contained in:
1 parent
75c73ce6f0
commit
7de378b41c
4 files changed
+161
No files matched your search
@@ -21,6 +21,23 @@ 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
|
||||
@@ -123,4 +140,77 @@ describe("founder⇄CEO chat", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user