fix: prevent nested .fusion/.fusion dir from PluginStore path bug
PluginStore's constructor treats its rootDir arg as a project root and internally appends `.fusion` before opening the SQLite DB. Several CLI call sites were passing the already-resolved `.fusion` directory, producing a doubled `.fusion/.fusion/fusion.db` that the dashboard process kept recreating on every project load. Pass the project root instead so the DB lands in the canonical `.fusion/fusion.db` alongside the rest of the project's state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1991,6 +1991,7 @@ describe("HeartbeatMonitor", () => {
|
||||
createMessage({
|
||||
id: "msg-1",
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
content: "Hello from agent-2",
|
||||
createdAt: "2024-01-15T10:30:00.000Z",
|
||||
}),
|
||||
@@ -2031,8 +2032,8 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
expect(executionPrompt).toContain("Pending Messages:");
|
||||
expect(executionPrompt).toContain("Hello from agent-2");
|
||||
expect(executionPrompt).toContain("Hello from user");
|
||||
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
|
||||
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
|
||||
});
|
||||
|
||||
it("does not include message section when no unread messages", async () => {
|
||||
@@ -2320,8 +2321,8 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(executionPrompt).toContain("Hello from agent-2");
|
||||
});
|
||||
|
||||
describe("end-to-end agent-to-agent message flow", () => {
|
||||
it("proves full message flow from send to wake to processing to reply", async () => {
|
||||
describe("end-to-end message flow", () => {
|
||||
it("proves wake-on-message can surface a user message and send a linked reply", async () => {
|
||||
const messages: Map<string, Message[]> = new Map();
|
||||
let messageCounter = 0;
|
||||
|
||||
@@ -2365,27 +2366,15 @@ describe("HeartbeatMonitor", () => {
|
||||
}),
|
||||
} as unknown as MessageStore;
|
||||
|
||||
// Agent A sends a message to Agent B
|
||||
const sentMessage = fakeMessageStore.sendMessage({
|
||||
fromId: "agent-alpha",
|
||||
fromType: "agent",
|
||||
const inboundFromUser = fakeMessageStore.sendMessage({
|
||||
fromId: "dashboard",
|
||||
fromType: "user",
|
||||
toId: "agent-beta",
|
||||
toType: "agent",
|
||||
content: "Hello Agent Beta, please process task FN-001.",
|
||||
type: "agent-to-agent",
|
||||
metadata: { taskId: "FN-001" },
|
||||
content: "Can you post a status update?",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
|
||||
expect(sentMessage.id).toBeDefined();
|
||||
expect(sentMessage.content).toContain("Hello Agent Beta");
|
||||
|
||||
// Verify message is stored in Agent B's inbox
|
||||
const inbox = fakeMessageStore.getInbox("agent-beta", "agent", { read: false });
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].id).toBe(sentMessage.id);
|
||||
expect(inbox[0].content).toBe(sentMessage.content);
|
||||
|
||||
// Create a monitor for Agent B with fake MessageStore
|
||||
const store = createStoreWithAgentForExec({
|
||||
id: "agent-beta",
|
||||
name: "Agent Beta",
|
||||
@@ -2402,25 +2391,40 @@ describe("HeartbeatMonitor", () => {
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
|
||||
// Execute heartbeat to process the message (simulating wake-on-message trigger)
|
||||
const result = await monitor.executeHeartbeat({
|
||||
agentId: "agent-beta",
|
||||
source: "on_demand",
|
||||
triggerDetail: "wake-on-message",
|
||||
});
|
||||
|
||||
// Verify heartbeat completed
|
||||
expect(result.status).toBe("completed");
|
||||
|
||||
// Verify the execution prompt included the message
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
expect(executionPrompt).toContain("Hello Agent Beta");
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1]?.[0] as string;
|
||||
expect(executionPrompt).toContain("Pending Messages:");
|
||||
expect(executionPrompt).toContain(`[id: ${inboundFromUser.id}]`);
|
||||
expect(executionPrompt).toContain("dashboard");
|
||||
|
||||
// Verify messages were marked as read after successful processing
|
||||
expect(fakeMessageStore.getMailbox("agent-beta", "agent").unreadCount).toBe(0);
|
||||
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
|
||||
const sendMessageTool = callArgs.customTools?.find((tool: { name: string }) => tool.name === "send_message");
|
||||
expect(sendMessageTool).toBeDefined();
|
||||
|
||||
await sendMessageTool!.execute(
|
||||
"tool-call",
|
||||
{
|
||||
to_id: "dashboard",
|
||||
content: "Status: I am on it.",
|
||||
type: "agent-to-user",
|
||||
reply_to_message_id: inboundFromUser.id,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const dashboardInbox = fakeMessageStore.getInbox("dashboard", "user");
|
||||
const linkedReply = dashboardInbox.find((message) => message.content === "Status: I am on it.");
|
||||
expect(linkedReply?.metadata).toEqual({ replyTo: { messageId: inboundFromUser.id } });
|
||||
|
||||
await monitor.stop();
|
||||
});
|
||||
@@ -2639,6 +2643,11 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("## Memory Boundaries");
|
||||
});
|
||||
|
||||
it("both prompts instruct replies to include reply_to_message_id", () => {
|
||||
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("reply_to_message_id");
|
||||
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("no-task system prompt processing messages section does not reference task_log", () => {
|
||||
const processingMessagesSection = HEARTBEAT_NO_TASK_SYSTEM_PROMPT.split("## Processing Messages")[1] ?? "";
|
||||
expect(processingMessagesSection).not.toContain("task_log");
|
||||
|
||||
@@ -155,12 +155,14 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
1. Use read_messages to check your inbox for unread messages.
|
||||
2. Review each message and determine the appropriate action:
|
||||
- If the message requires a response, use send_message to reply.
|
||||
- When replying, include 'reply_to_message_id' with the original message ID from read_messages output.
|
||||
- If the message is informational, acknowledge it by logging with task_log.
|
||||
- If the message requests work, create a follow-up task with task_create or handle it directly.
|
||||
3. After processing messages, continue with your normal heartbeat duties.
|
||||
|
||||
When sending messages:
|
||||
- Be concise and clear about what you need or what you've done.
|
||||
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
|
||||
- Include relevant context (task IDs, file paths) in metadata when applicable.
|
||||
- Use agent-to-agent for inter-agent communication.`;
|
||||
|
||||
@@ -198,12 +200,14 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
1. If read_messages is available, use it to check your inbox for unread messages.
|
||||
2. Review each message and determine the appropriate action:
|
||||
- If the message requires a response and send_message is available, use send_message to reply.
|
||||
- When replying, include 'reply_to_message_id' with the original message ID from read_messages output.
|
||||
- If the message is informational, acknowledge it and respond via send_message when appropriate.
|
||||
- If the message requests work, create a follow-up task with task_create.
|
||||
3. After processing messages, continue with your ambient work.
|
||||
|
||||
When sending messages:
|
||||
- Be concise and clear about what you need or what you've done.
|
||||
- Use 'reply_to_message_id' when replying so threaded conversations stay linked.
|
||||
- Include relevant context (task IDs, file paths) in metadata when applicable.
|
||||
- Use agent-to-agent for inter-agent communication.`;
|
||||
|
||||
@@ -1202,7 +1206,7 @@ export class HeartbeatMonitor {
|
||||
"Pending Messages:",
|
||||
...pendingMessages.map((msg) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
return `- [id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1217,7 +1221,7 @@ export class HeartbeatMonitor {
|
||||
"useful ambient work. Here are some things you can do:",
|
||||
"",
|
||||
"1. **Check your messages** — Use read_messages to review any pending messages",
|
||||
" and use send_message to respond or communicate with other agents.",
|
||||
" and use send_message with reply_to_message_id when responding.",
|
||||
"",
|
||||
"2. **Create new tasks** — Use task_create to spawn follow-up work that needs",
|
||||
" to be done. This is useful for surfacing issues or ideas you discover.",
|
||||
@@ -1286,7 +1290,7 @@ export class HeartbeatMonitor {
|
||||
"Pending Messages:",
|
||||
...pendingMessages.map((msg) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
return `- [id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,8 +384,9 @@ describe("createSendMessageTool", () => {
|
||||
expect(tool.label).toBe("Send Message");
|
||||
});
|
||||
|
||||
it("creates a tool with a description mentioning recipient waking", () => {
|
||||
it("creates a tool with a description mentioning recipient waking and reply linking", () => {
|
||||
expect(tool.description).toContain("messageResponseMode");
|
||||
expect(tool.description).toContain("reply_to_message_id");
|
||||
});
|
||||
|
||||
it("calls messageStore.sendMessage with correct parameters", async () => {
|
||||
@@ -453,6 +454,32 @@ describe("createSendMessageTool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("persists reply metadata when reply_to_message_id is provided", async () => {
|
||||
const mockMessage = createMessage({ id: "msg-reply" });
|
||||
vi.mocked(messageStore.sendMessage).mockReturnValue(mockMessage);
|
||||
|
||||
await executeTool(tool, {
|
||||
to_id: "agent-2",
|
||||
content: "Following up",
|
||||
reply_to_message_id: "msg-original",
|
||||
});
|
||||
|
||||
expect(messageStore.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadata: { replyTo: { messageId: "msg-original" } } })
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects blank reply_to_message_id", async () => {
|
||||
const result = await executeTool(tool, {
|
||||
to_id: "agent-2",
|
||||
content: "Following up",
|
||||
reply_to_message_id: " ",
|
||||
});
|
||||
|
||||
expect(result.content[0]).toEqual({ type: "text", text: "ERROR: reply_to_message_id must be a non-empty string" });
|
||||
expect(messageStore.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error for empty content", async () => {
|
||||
const result = await executeTool(tool, {
|
||||
to_id: "agent-2",
|
||||
@@ -570,11 +597,12 @@ describe("createReadMessagesTool", () => {
|
||||
expect(result.content[0]).toEqual({ type: "text", text: "No messages" });
|
||||
});
|
||||
|
||||
it("returns formatted message list with sender, content, and timestamp", async () => {
|
||||
it("returns formatted message list with message IDs, sender, content, and timestamp", async () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
id: "msg-1",
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
content: "Hello there",
|
||||
createdAt: "2024-01-15T10:30:00.000Z",
|
||||
read: false,
|
||||
@@ -582,6 +610,7 @@ describe("createReadMessagesTool", () => {
|
||||
createMessage({
|
||||
id: "msg-2",
|
||||
fromId: "user-1",
|
||||
fromType: "user",
|
||||
content: "Another message",
|
||||
createdAt: "2024-01-15T11:00:00.000Z",
|
||||
read: true,
|
||||
@@ -594,8 +623,8 @@ describe("createReadMessagesTool", () => {
|
||||
const text = result.content[0];
|
||||
expect(text).toMatchObject({ type: "text" });
|
||||
expect((text as { text: string }).text).toContain("Messages (2)");
|
||||
expect((text as { text: string }).text).toContain("[unread] [from: agent-2] Hello there");
|
||||
expect((text as { text: string }).text).toContain("[read] [from: user-1] Another message");
|
||||
expect((text as { text: string }).text).toContain("[unread] [id: msg-1] [from: agent:agent-2] Hello there");
|
||||
expect((text as { text: string }).text).toContain("[read] [id: msg-2] [from: user:user-1] Another message");
|
||||
expect(result.details).toEqual({ messages });
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ export const sendMessageParams = Type.Object({
|
||||
Type.Literal("agent-to-agent"),
|
||||
Type.Literal("agent-to-user"),
|
||||
], { description: "Message type (defaults to 'agent-to-agent')" })),
|
||||
reply_to_message_id: Type.Optional(
|
||||
Type.String({ description: "Optional ID of the message you are replying to (use IDs from read_messages output)" }),
|
||||
),
|
||||
});
|
||||
|
||||
export const readMessagesParams = Type.Object({
|
||||
@@ -928,7 +931,8 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
||||
label: "Send Message",
|
||||
description:
|
||||
"Send a message to another agent or user. The recipient will be woken if they have " +
|
||||
"`messageResponseMode: 'immediate'` configured.",
|
||||
"`messageResponseMode: 'immediate'` configured. When replying to an existing message, " +
|
||||
"include `reply_to_message_id` to preserve threading.",
|
||||
parameters: sendMessageParams,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
execute: async (_id: string, params: Static<typeof sendMessageParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
|
||||
@@ -950,6 +954,14 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
||||
try {
|
||||
const messageType = params.type ?? "agent-to-agent";
|
||||
const recipientType = messageType === "agent-to-user" ? "user" : "agent";
|
||||
const replyToMessageId = params.reply_to_message_id?.trim();
|
||||
|
||||
if (params.reply_to_message_id !== undefined && !replyToMessageId) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: reply_to_message_id must be a non-empty string" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
const message = messageStore.sendMessage({
|
||||
fromId: fromAgentId,
|
||||
@@ -958,6 +970,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
||||
toType: recipientType,
|
||||
content,
|
||||
type: messageType,
|
||||
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1014,7 +1027,7 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
|
||||
const lines = messages.map((msg: Message) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
const readStatus = msg.read ? "[read] " : "[unread] ";
|
||||
return `${readStatus}[from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
return `${readStatus}[id: ${msg.id}] [from: ${msg.fromType}:${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -182,6 +182,9 @@ import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
|
||||
import { createFnAgent } from "./pi.js";
|
||||
import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { findWorktreeUser, aiMergeTask } from "./merger.js";
|
||||
import { WorktreePool } from "./worktree-pool.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
@@ -8119,6 +8122,119 @@ describe("Workflow Steps Execution", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("routes exhausted prompt-mode workflow hard failures back to remediation and only reopens the last step", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "fn-2301-workflow-"));
|
||||
const fusionDir = join(tempRoot, ".fusion");
|
||||
const promptPath = join(fusionDir, "tasks", "FN-001", "PROMPT.md");
|
||||
await mkdir(join(fusionDir, "tasks", "FN-001"), { recursive: true });
|
||||
await writeFile(promptPath, "# Task\n\n## Steps\n\n- [x] Step 0\n- [x] Step 1\n", "utf-8");
|
||||
store.getFusionDir.mockReturnValue(fusionDir);
|
||||
|
||||
const mutableTask = {
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress" as const,
|
||||
dependencies: [] as string[],
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" as const },
|
||||
{ name: "Step 1", status: "done" as const },
|
||||
],
|
||||
currentStep: 1,
|
||||
log: [] as any[],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
workflowStepRetries: 3,
|
||||
prompt: "# test\n## Steps\n### Step 0\n- [x] done\n### Step 1\n- [x] done",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.getTask.mockImplementation(async () => mutableTask);
|
||||
|
||||
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
||||
if (mutableTask.steps[stepIndex]) {
|
||||
mutableTask.steps[stepIndex].status = status as any;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Frontend UX Design",
|
||||
description: "Verify UX polish",
|
||||
mode: "prompt",
|
||||
prompt: "Review and report issues.",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let callIdx = 0;
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
callIdx++;
|
||||
if (callIdx === 1) {
|
||||
const customTools = opts.customTools || [];
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
||||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
};
|
||||
return { session };
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("Quality gate hard failure: spacing regression in dashboard cards")),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onError });
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await executor.execute({ ...mutableTask });
|
||||
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.stringContaining("Workflow step failed"),
|
||||
"agent",
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
||||
|
||||
const updateStepCalls = store.updateStep.mock.calls
|
||||
.filter((call: any[]) => call[0] === "FN-001" && call[2] === "pending")
|
||||
.map((call: any[]) => call[1]);
|
||||
expect(updateStepCalls).toContain(1);
|
||||
expect(updateStepCalls).not.toContain(0);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
const promptContent = await readFile(promptPath, "utf-8");
|
||||
expect(promptContent).toContain("## Workflow Step Failure");
|
||||
expect(promptContent).toContain("Frontend UX Design");
|
||||
expect(promptContent).toContain("Quality gate hard failure");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips script-mode step when scriptName is missing", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user