Merge commit '6fdfb1ad5cedc12afec1300d39a9a1d445d7b6f7'
This commit is contained in:
@@ -109,7 +109,11 @@ exports[`agent-heartbeat procedure templates > keeps strict no-task procedure st
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -147,7 +151,11 @@ exports[`agent-heartbeat procedure templates > keeps strict task procedure stabl
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStore, ChatStore, TaskStore } from "@fusion/core";
|
||||
import { HeartbeatMonitor } from "../agent-heartbeat.js";
|
||||
import * as roomCoordination from "../room-coordination.js";
|
||||
|
||||
const sessionCapture = vi.hoisted(() => ({
|
||||
prompt: "",
|
||||
@@ -283,6 +284,176 @@ describe("heartbeat room messages", () => {
|
||||
expect(sessionCapture.prompt).not.toContain("Resolved Referent:");
|
||||
});
|
||||
|
||||
describe("multi-agent room coordination (FN-5425)", () => {
|
||||
async function seedMultiAgentRoom(
|
||||
localHarness: Harness,
|
||||
{ peerAgentId = "agent-peer", roomName }: { peerAgentId?: string; roomName: string },
|
||||
): Promise<{ room: ReturnType<ChatStore["createRoom"]>; peerAgentId: string }> {
|
||||
const peerAgent = await localHarness.agentStore.createAgent({
|
||||
name: peerAgentId,
|
||||
role: "executor",
|
||||
soul: "Peer room member",
|
||||
runtimeConfig: { enabled: true },
|
||||
});
|
||||
const room = localHarness.chatStore.createRoom({ name: roomName, memberAgentIds: [localHarness.agentId] });
|
||||
localHarness.chatStore.addRoomMember(room.id, peerAgent.id);
|
||||
return { room, peerAgentId: peerAgent.id };
|
||||
}
|
||||
|
||||
it("renders claim branch and emits coordination audit in multi-agent room", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-claim" });
|
||||
const userMessage = harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "user",
|
||||
content: "please file a task for the secrets-sync regression",
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Coordination Notices:");
|
||||
expect(sessionCapture.prompt).toContain("branch: claim");
|
||||
expect(sessionCapture.prompt).toContain("Claiming:");
|
||||
expect(sessionCapture.prompt).toContain("fn_task_create");
|
||||
expect(sessionCapture.prompt).toContain("fn_post_room_message");
|
||||
expect(sessionCapture.prompt).toContain("FN-4918");
|
||||
|
||||
const event = harness.taskStore
|
||||
.getRunAuditEvents({ runId: run!.id })
|
||||
.find((auditEvent) => auditEvent.mutationType === "room:coordination:branch" && auditEvent.target === userMessage.id);
|
||||
expect(event?.metadata).toMatchObject({ branch: "claim" });
|
||||
});
|
||||
|
||||
it("renders defer branch when peer already claimed", async () => {
|
||||
harness = await createHarness();
|
||||
const { room, peerAgentId } = await seedMultiAgentRoom(harness, { roomName: "coord-defer-claim" });
|
||||
const priorClaim = harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: peerAgentId,
|
||||
content: "Claiming: filing task for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "user",
|
||||
content: "please file a task for the secrets-sync regression",
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
expect(sessionCapture.prompt).toContain("branch: defer-suggested");
|
||||
expect(sessionCapture.prompt).toContain(peerAgentId);
|
||||
expect(sessionCapture.prompt).toContain("Do NOT call fn_task_create");
|
||||
|
||||
const event = harness.taskStore
|
||||
.getRunAuditEvents({ runId: run!.id })
|
||||
.find((auditEvent) => auditEvent.mutationType === "room:coordination:branch");
|
||||
expect(event?.metadata).toMatchObject({ branch: "defer-suggested", priorClaimMessageId: priorClaim.id });
|
||||
});
|
||||
|
||||
it("captures prior task id from peer announcement", async () => {
|
||||
harness = await createHarness();
|
||||
const { room, peerAgentId } = await seedMultiAgentRoom(harness, { roomName: "coord-defer-task" });
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: peerAgentId,
|
||||
content: "Filed FN-9042 for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
expect(sessionCapture.prompt).toContain("FN-9042");
|
||||
const event = harness.taskStore.getRunAuditEvents({ runId: run!.id }).find((auditEvent) => auditEvent.mutationType === "room:coordination:branch");
|
||||
expect(event?.metadata).toMatchObject({ priorTaskId: "FN-9042" });
|
||||
});
|
||||
|
||||
it("does not render coordination notices for single-agent room", async () => {
|
||||
harness = await createHarness();
|
||||
const room = harness.chatStore.createRoom({ name: "single-agent", memberAgentIds: [harness.agentId] });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "file a task for X" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
expect(harness.taskStore.getRunAuditEvents({ runId: run!.id }).some((event) => event.mutationType === "room:coordination:branch")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not render coordination notices for non-task-filing content", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-non-intent" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "what do you think about the secrets-sync regression?" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
expect(harness.taskStore.getRunAuditEvents({ runId: run!.id }).some((event) => event.mutationType === "room:coordination:branch")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps deictic-only messages in ambiguity layer only", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-deictic-only" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "we should investigate the secrets-sync regression" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "yeah, create it" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Ambiguity Notices:");
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
});
|
||||
|
||||
it("does not defer to a self-authored prior claim", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-self-claim" });
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: harness.agentId,
|
||||
content: "Claiming: filing task for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("branch: claim");
|
||||
expect(sessionCapture.prompt).not.toContain("branch: defer-suggested");
|
||||
});
|
||||
|
||||
it("fails open when coordination helper throws", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-throw" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
const spy = vi.spyOn(roomCoordination, "decideRoomCoordination").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await expect(monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any })).resolves.toBeTruthy();
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it("registers fn_post_room_message and posts through the real ChatStore under restrictive policy", async () => {
|
||||
harness = await createHarness({
|
||||
presetId: "approval-required",
|
||||
|
||||
217
packages/engine/src/__tests__/room-coordination.test.ts
Normal file
217
packages/engine/src/__tests__/room-coordination.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { ChatRoomMember, ChatRoomMessage } from "@fusion/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
countActiveAgentMembers,
|
||||
decideRoomCoordination,
|
||||
detectTaskFilingIntent,
|
||||
renderRoomCoordinationPromptBlock,
|
||||
} from "../room-coordination.js";
|
||||
|
||||
function roomMember(agentId: string | null): ChatRoomMember {
|
||||
return {
|
||||
roomId: "room-1",
|
||||
// cast for test-only simulation of nullable rows from external callers
|
||||
agentId: agentId as unknown as string,
|
||||
role: "member",
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function roomMessage(id: string, content: string, senderAgentId: string | null = "agent-peer"): ChatRoomMessage {
|
||||
return {
|
||||
id,
|
||||
roomId: "room-1",
|
||||
role: senderAgentId ? "assistant" : "user",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
senderAgentId,
|
||||
mentions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("room-coordination", () => {
|
||||
describe("detectTaskFilingIntent", () => {
|
||||
it("detects explicit task-filing intent with extracted subject", () => {
|
||||
const result = detectTaskFilingIntent("please file a task for the secrets-sync regression");
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
expect(result.subject).toBe("the secrets-sync regression");
|
||||
expect(result.cues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Can you create a task to fix the broken merge?", "fix the broken merge"],
|
||||
["open a task about the new typecheck failure", "the new typecheck failure"],
|
||||
["track this as a task", null],
|
||||
["FILE A TASK: dashboard FAB regression.", "dashboard FAB regression"],
|
||||
["create a task for it", "it"],
|
||||
["create a task for FN-1234", "FN-1234"],
|
||||
])("handles positive variants: %s", (content, expectedSubject) => {
|
||||
const result = detectTaskFilingIntent(content);
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
if (expectedSubject === null) {
|
||||
expect([null, "this"]).toContain(result.subject);
|
||||
} else {
|
||||
expect(result.subject).toContain(expectedSubject);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file it",
|
||||
"create it now",
|
||||
"do that as a task",
|
||||
"this is a task list",
|
||||
"I filed the task report yesterday",
|
||||
"",
|
||||
" ",
|
||||
])("rejects non-intent content: %s", (content) => {
|
||||
const result = detectTaskFilingIntent(content);
|
||||
expect(result).toEqual({ isTaskFilingIntent: false, cues: [], subject: null });
|
||||
});
|
||||
|
||||
it("returns true for past-tense phrasing trade-off", () => {
|
||||
const result = detectTaskFilingIntent("I filed a task earlier");
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects oversized content", () => {
|
||||
const result = detectTaskFilingIntent(`create a task for ${"x".repeat(1000)}`);
|
||||
expect(result).toEqual({ isTaskFilingIntent: false, cues: [], subject: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("countActiveAgentMembers", () => {
|
||||
it("counts unique active agent members", () => {
|
||||
expect(countActiveAgentMembers([roomMember("a1"), roomMember("a2"), roomMember("a3")])).toBe(3);
|
||||
});
|
||||
|
||||
it("deduplicates duplicate agent ids", () => {
|
||||
expect(countActiveAgentMembers([roomMember("a1"), roomMember("a1"), roomMember("a2")])).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores non-agent members", () => {
|
||||
expect(countActiveAgentMembers([roomMember(null), roomMember("a1")])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideRoomCoordination", () => {
|
||||
const detection = detectTaskFilingIntent("please file a task for secrets sync");
|
||||
|
||||
it("returns null for non-intent detection", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection: { isTaskFilingIntent: false, cues: [], subject: null },
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for single-agent rooms", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1")],
|
||||
recentMessages: [],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns claim when no prior peer claim exists", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m1", "hello", null)],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
expect(result?.priorClaimMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns defer-suggested with prior peer claim", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m1", "Claiming: filing task for secrets sync", "agent-peer")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("defer-suggested");
|
||||
expect(result?.priorClaimMessageId).toBe("m1");
|
||||
expect(result?.priorClaimSenderId).toBe("agent-peer");
|
||||
});
|
||||
|
||||
it("returns defer-suggested with prior task announcement", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m2", "Filed FN-9001 for the secrets-sync regression", "agent-peer")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("defer-suggested");
|
||||
expect(result?.priorTaskId).toBe("FN-9001");
|
||||
});
|
||||
|
||||
it("does not defer to self-authored claim", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m3", "Claiming: filing task for secrets sync", "agent-main")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
});
|
||||
|
||||
it("skips pending message itself from prior lookup", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("pending", "Claiming: filing task for secrets sync", "agent-main")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderRoomCoordinationPromptBlock", () => {
|
||||
it("renders claim branch instructions with explicit guard references", () => {
|
||||
const lines = renderRoomCoordinationPromptBlock(
|
||||
{
|
||||
branch: "claim",
|
||||
memberCount: 3,
|
||||
detection: { isTaskFilingIntent: true, cues: ["file a task"], subject: "secrets-sync regression" },
|
||||
},
|
||||
{ id: "m5" },
|
||||
);
|
||||
|
||||
const block = lines.join("\n");
|
||||
expect(block).toContain("fn_post_room_message");
|
||||
expect(block).toContain("fn_task_create");
|
||||
expect(block).toContain("FN-4918");
|
||||
expect(block).toContain("FN-4829");
|
||||
expect(block).toContain("FN-5152");
|
||||
expect(block).toContain("FN-5220");
|
||||
expect(block).toContain("reply_to_message_id = m5");
|
||||
});
|
||||
|
||||
it("renders defer branch with peer, message, and prior task id", () => {
|
||||
const lines = renderRoomCoordinationPromptBlock(
|
||||
{
|
||||
branch: "defer-suggested",
|
||||
memberCount: 2,
|
||||
detection: { isTaskFilingIntent: true, cues: ["create a task"], subject: "secrets-sync regression" },
|
||||
priorClaimMessageId: "m4",
|
||||
priorClaimSenderId: "agent-peer",
|
||||
priorTaskId: "FN-9042",
|
||||
},
|
||||
{ id: "m6" },
|
||||
);
|
||||
|
||||
const block = lines.join("\n");
|
||||
expect(block).toContain("agent-peer");
|
||||
expect(block).toContain("message m4");
|
||||
expect(block).toContain("FN-9042");
|
||||
expect(block).toContain("reply_to_message_id = m6");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user