feat(FN-3593): add test isolation CI enforcement, fix stuck-requeue race, a
This merge lands five FN-3593 commits establishing a test isolation contract with a new `scripts/check-test-isolation.mjs` guard that scans for accidental `beforeEach`/`afterEach`/`beforeAll`/`afterAll` in setup helpers, plus per-package `setup-test-isolation.ts` bootstraps that canonicalize the pat Fusion-Task-Id: FN-3593
This commit is contained in:
@@ -220,6 +220,160 @@ describe("wake-on-message", () => {
|
||||
customMonitor.stop();
|
||||
});
|
||||
|
||||
it("forces a wake when metadata.wakeRecipient overrides on-heartbeat mode", () => {
|
||||
let messageHook: ((message: Message) => void) | undefined;
|
||||
const messageStore = createMockMessageStore((hook) => {
|
||||
messageHook = hook;
|
||||
});
|
||||
const configStore = createMockStore({
|
||||
getCachedAgent: vi.fn().mockReturnValue({
|
||||
id: "agent-1",
|
||||
state: "active",
|
||||
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||
}),
|
||||
});
|
||||
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
agentStore: configStore,
|
||||
messageStore,
|
||||
});
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(customMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||
|
||||
customMonitor.start();
|
||||
messageHook?.(
|
||||
createMessage({
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
source: "on_demand",
|
||||
triggerDetail: "wake-on-message-forced",
|
||||
});
|
||||
|
||||
customMonitor.stop();
|
||||
});
|
||||
|
||||
it("forces a wake even when messageResponseMode is unset", () => {
|
||||
let messageHook: ((message: Message) => void) | undefined;
|
||||
const messageStore = createMockMessageStore((hook) => {
|
||||
messageHook = hook;
|
||||
});
|
||||
const configStore = createMockStore({
|
||||
getCachedAgent: vi.fn().mockReturnValue({
|
||||
id: "agent-1",
|
||||
state: "idle",
|
||||
runtimeConfig: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
agentStore: configStore,
|
||||
messageStore,
|
||||
});
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(customMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||
|
||||
customMonitor.start();
|
||||
messageHook?.(
|
||||
createMessage({
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
source: "on_demand",
|
||||
triggerDetail: "wake-on-message-forced",
|
||||
});
|
||||
|
||||
customMonitor.stop();
|
||||
});
|
||||
|
||||
it("ignores wakeRecipient metadata when sender is an agent (only humans may force wakes)", () => {
|
||||
let messageHook: ((message: Message) => void) | undefined;
|
||||
const messageStore = createMockMessageStore((hook) => {
|
||||
messageHook = hook;
|
||||
});
|
||||
const configStore = createMockStore({
|
||||
getCachedAgent: vi.fn().mockReturnValue({
|
||||
id: "agent-1",
|
||||
state: "active",
|
||||
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||
}),
|
||||
});
|
||||
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
agentStore: configStore,
|
||||
messageStore,
|
||||
});
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(customMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||
|
||||
customMonitor.start();
|
||||
messageHook?.(
|
||||
createMessage({
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
fromId: "agent-2",
|
||||
fromType: "agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||
|
||||
customMonitor.stop();
|
||||
});
|
||||
|
||||
it("still respects state gating when wakeRecipient is set (paused agent stays paused)", () => {
|
||||
let messageHook: ((message: Message) => void) | undefined;
|
||||
const messageStore = createMockMessageStore((hook) => {
|
||||
messageHook = hook;
|
||||
});
|
||||
const configStore = createMockStore({
|
||||
getCachedAgent: vi.fn().mockReturnValue({
|
||||
id: "agent-1",
|
||||
state: "paused",
|
||||
runtimeConfig: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const customMonitor = new HeartbeatMonitor({
|
||||
store,
|
||||
agentStore: configStore,
|
||||
messageStore,
|
||||
});
|
||||
const executeHeartbeatSpy = vi
|
||||
.spyOn(customMonitor, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||
|
||||
customMonitor.start();
|
||||
messageHook?.(
|
||||
createMessage({
|
||||
toId: "agent-1",
|
||||
toType: "agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||
|
||||
customMonitor.stop();
|
||||
});
|
||||
|
||||
it("registers the message hook on start and clears it on stop", () => {
|
||||
const hooks: Array<(message: Message) => void> = [];
|
||||
const messageStore = createMockMessageStore((hook) => {
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
/**
|
||||
* Global test isolation: prevents engine tests from writing to the real ~/.fusion/ directory.
|
||||
*
|
||||
* This runs in every Vitest worker before shared setup. By forcing process.env.HOME
|
||||
* to a fresh temp directory, homedir()-derived paths resolve to isolated locations.
|
||||
* Deprecated shim: canonical test isolation is in @fusion/core vitest-setup.
|
||||
*/
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
if (process.platform === "win32") {
|
||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (match) {
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
}
|
||||
import "../../../core/src/__test-utils__/vitest-setup";
|
||||
|
||||
@@ -1149,7 +1149,12 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
||||
const senderForcedWake = message.metadata?.wakeRecipient === true;
|
||||
// Only human-originated (user) messages may override an agent's
|
||||
// messageResponseMode setting. Agent-to-agent traffic must respect the
|
||||
// recipient's configured behavior to prevent agents from forcing wakes
|
||||
// on each other.
|
||||
const senderForcedWake =
|
||||
message.metadata?.wakeRecipient === true && message.fromType === "user";
|
||||
if (!senderForcedWake && runtimeConfig?.messageResponseMode !== "immediate") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,13 +113,6 @@ export const sendMessageParams = Type.Object({
|
||||
reply_to_message_id: Type.Optional(
|
||||
Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }),
|
||||
),
|
||||
wake_recipient: Type.Optional(
|
||||
Type.Boolean({
|
||||
description:
|
||||
"If true, wake the recipient agent immediately on receipt regardless of their messageResponseMode. " +
|
||||
"Use sparingly for urgent messages. Ignored when the recipient is a user.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const readMessagesParams = Type.Object({
|
||||
@@ -1417,8 +1410,7 @@ 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, or if you set `wake_recipient: true` " +
|
||||
"to override their setting for an urgent message. When replying to an existing message, " +
|
||||
"`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
|
||||
@@ -1453,15 +1445,6 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
||||
};
|
||||
}
|
||||
|
||||
const wakeRecipient = params.wake_recipient === true && recipient.type === "agent";
|
||||
const metadata =
|
||||
replyToMessageId || wakeRecipient
|
||||
? {
|
||||
...(replyToMessageId ? { replyTo: { messageId: replyToMessageId } } : {}),
|
||||
...(wakeRecipient ? { wakeRecipient: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const message = messageStore.sendMessage({
|
||||
fromId: fromAgentId,
|
||||
fromType: "agent",
|
||||
@@ -1469,7 +1452,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
||||
toType: recipient.type,
|
||||
content,
|
||||
type: messageType,
|
||||
...(metadata ? { metadata } : {}),
|
||||
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,7 +16,6 @@ export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
setupFiles: [
|
||||
"./src/__tests__/setup-test-isolation.ts",
|
||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||
],
|
||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||
|
||||
Reference in New Issue
Block a user