feat(FN-3588): add immediate wake controls for agent inbox and message API

The merge delivers four major bodies of work. The dominant theme is FN-3588, which adds an "immediate wake" override to the message inbox API, exposes it in the MailboxModal UI, and updates `agent-heartbeat.ts` and `executor.ts` to honor the override alongside timer/signal triggers. FN-3705 gates re

Fusion-Task-Id: FN-3588
This commit is contained in:
Fusion
2026-05-07 10:41:11 -07:00
committed by gsxdsm
parent 66fa56b45e
commit 66c66ec79a
10 changed files with 358 additions and 26 deletions

View File

@@ -3104,6 +3104,198 @@ describe("Messaging Routes", () => {
expect(res.body.id).toBe("msg-runtime-1");
});
it("triggers executeHeartbeat when wakeImmediately is true for agent recipients", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-1",
toType: "agent",
content: "wake now",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(executeHeartbeat).toHaveBeenCalledWith({
agentId: "agent-wake-1",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
it("does not trigger executeHeartbeat when wakeImmediately is omitted/false or recipient is not an agent", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const noWake = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-no-wake",
toType: "agent",
content: "normal message",
type: "user-to-agent",
}),
{ "Content-Type": "application/json" },
);
const userRecipient = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "dashboard",
toType: "user",
content: "user message",
type: "system",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(noWake.status).toBe(201);
expect(userRecipient.status).toBe(201);
expect(executeHeartbeat).not.toHaveBeenCalled();
});
it("uses project-scoped heartbeat monitor resolution when default monitor belongs to another root", async () => {
const defaultExecuteHeartbeat = vi.fn().mockResolvedValue({ id: "run-default" });
const projectExecuteHeartbeat = vi.fn().mockResolvedValue({ id: "run-project" });
const engineManager = {
getAllEngines: vi.fn().mockReturnValue(new Map([
[
"project-1",
{
getWorkingDirectory: () => rootDir,
getHeartbeatMonitor: () => ({ executeHeartbeat: projectExecuteHeartbeat }),
},
],
])),
};
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat: defaultExecuteHeartbeat, rootDir: join(rootDir, "other-project") } as any,
engineManager: engineManager as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-project-scope",
toType: "agent",
content: "wake in scoped project",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(defaultExecuteHeartbeat).not.toHaveBeenCalled();
expect(projectExecuteHeartbeat).toHaveBeenCalledWith({
agentId: "agent-project-scope",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
it("returns created message even when wakeImmediately execution throws", async () => {
const executeHeartbeat = vi.fn().mockRejectedValue(new Error("wake failed"));
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-failure",
toType: "agent",
content: "wake best effort",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.toId).toBe("agent-wake-failure");
expect(executeHeartbeat).toHaveBeenCalledTimes(1);
});
it("supports metadata.wakeRecipient as an immediate-wake request", async () => {
const executeHeartbeat = vi.fn().mockResolvedValue({ id: "run-1" });
const wakeApp = express();
wakeApp.use(express.json());
wakeApp.use("/api", createApiRoutes(store, {
heartbeatMonitor: { executeHeartbeat, rootDir } as any,
}));
const res = await REQUEST(
wakeApp,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-wake-meta",
toType: "agent",
content: "wake via metadata",
type: "user-to-agent",
metadata: { wakeRecipient: true },
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(executeHeartbeat).toHaveBeenCalledWith({
agentId: "agent-wake-meta",
source: "on_demand",
triggerDetail: "wake-on-message",
});
});
it("gracefully no-ops wakeImmediately when no heartbeat monitor is configured", async () => {
const res = await REQUEST(
app,
"POST",
"/api/messages",
JSON.stringify({
toId: "agent-no-monitor",
toType: "agent",
content: "wake request without monitor",
type: "user-to-agent",
wakeImmediately: true,
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body.toId).toBe("agent-no-monitor");
});
it("GET /api/messages/inbox returns dashboard inbox messages", async () => {
const inboxMessage = messageStore.sendMessage({
fromId: "agent-1",
@@ -3231,6 +3423,16 @@ describe("Messaging Routes", () => {
},
message: "metadata.replyTo.messageId must be a non-empty string",
},
{
body: {
toId: "agent-1",
toType: "agent",
content: "x",
type: "user-to-agent",
wakeImmediately: "yes",
},
message: "wakeImmediately must be a boolean",
},
];
for (const testCase of invalidCases) {