feat(FN-5425): add coordination notice system for multi-agent room task fil

Adds a `room-coordination.ts` helper module for multi-agent coordination messaging, wires coordination notices into the agent heartbeat path, and ships tests plus documentation for the feature. A changeset prepares the `@runfusion/fusion` package for release.

Fusion-Task-Id: FN-5425
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 00:10:32 -07:00
committed by gsxdsm
parent 5c15031416
commit 6fdfb1ad5c
9 changed files with 752 additions and 7 deletions

View File

@@ -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.

View File

@@ -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",

View 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");
});
});
});