singularity-forge/src/resources/extensions/sf/tests/swarm-dispatch-and-wait.test.mjs
2026-05-15 07:35:31 +02:00

799 lines
26 KiB
JavaScript

/**
* swarm-dispatch-and-wait.test.mjs — Tests for SwarmDispatchLayer.dispatchAndWait().
*
* Purpose: verify that dispatchAndWait enqueues an envelope, drives runAgentTurn,
* reads the agent reply from the bus, and returns the structured result with `reply`
* and `replyMessageId` populated.
*
* Consumer: CI unit-test suite (`npm run test:unit`).
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { _clearSfRootCache } from "../paths.js";
import { closeDatabase } from "../sf-db.js";
import {
SwarmDispatchLayer,
swarmDispatchAndWait,
} from "../uok/swarm-dispatch.js";
import { createDefaultSwarm } from "../uok/swarm-roles.js";
// ─── Mock agent-runner.js ─────────────────────────────────────────────────────
// We stub runAgentTurn so the test never spawns an LLM subprocess. The mock
// writes a deterministic reply to the bus and returns turnsProcessed + replyId,
// exactly as the real implementation does.
const MOCK_REPLY_TEXT = "mock agent reply: task complete";
vi.mock("../uok/agent-runner.js", () => ({
runAgentTurn: vi.fn(async (agent, opts = {}) => {
const { onlyMessageId } = opts;
// When onlyMessageId is set, force-refresh the inbox from SQLite so
// messages delivered via a different MessageBus instance are visible.
// This mirrors the real runAgentTurn's Bug 1 fix.
if (onlyMessageId) {
agent._inbox.refresh();
}
// Isolate to the target message when onlyMessageId is provided, otherwise
// read all unread messages — mirrors the real runAgentTurn logic.
let messages;
if (onlyMessageId) {
const all = agent.receive(false); // all messages (read + unread)
const target = all.find((m) => m.id === onlyMessageId && !m.read);
messages = target ? [target] : [];
} else {
messages = agent.receive(true);
}
if (messages.length === 0) {
return { turnsProcessed: 0, response: null };
}
// Mark messages as read
for (const msg of messages) {
agent.markRead(msg.id);
}
// Write a deterministic reply back to the bus — replyTo the last message
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return {
turnsProcessed: messages.length,
response: MOCK_REPLY_TEXT,
replyId,
};
}),
}));
// ─── Shared Setup ─────────────────────────────────────────────────────────────
const tmpRoots = [];
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
closeDatabase();
_clearSfRootCache();
for (const root of tmpRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeProject() {
const root = mkdtempSync(join(tmpdir(), "sf-daw-"));
tmpRoots.push(root);
return root;
}
// ─── dispatchAndWait — happy path ─────────────────────────────────────────────
describe("SwarmDispatchLayer.dispatchAndWait — happy path", () => {
test("returns messageId, targetAgent, reply, and replyMessageId", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-1",
unitType: "task",
workMode: "build",
payload: "implement feature X",
priority: 5,
scope: "test-scope",
});
expect(result.messageId).toBeTruthy();
expect(result.targetAgent).toBeTruthy();
expect(result.reply).toBe(MOCK_REPLY_TEXT);
expect(result.replyMessageId).toBeTruthy();
expect(typeof result.replyMessageId).toBe("string");
expect(result.error).toBeUndefined();
});
test("targetAgent is a worker for workMode=build", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-2",
unitType: "task",
workMode: "build",
payload: "build something",
priority: 3,
scope: "scope-A",
});
const swarm = await layer.getOrCreateSwarm();
const agent = swarm.get(result.targetAgent);
expect(agent.identity.role).toBe("worker");
});
test("targetAgent is a scout for workMode=research", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-3",
unitType: "research",
workMode: "research",
payload: "research topic Y",
priority: 2,
scope: "scope-B",
});
const swarm = await layer.getOrCreateSwarm();
const agent = swarm.get(result.targetAgent);
expect(agent.identity.role).toBe("scout");
});
});
// ─── swarmDispatchAndWait — convenience function ───────────────────────────────
describe("swarmDispatchAndWait — convenience function", () => {
test("delegates to SwarmDispatchLayer and returns structured result", async () => {
const root = makeProject();
// Pre-create the swarm so dispatch can route
await createDefaultSwarm(root);
const result = await swarmDispatchAndWait(root, {
unitId: "task-daw-convenience",
unitType: "task",
workMode: "build",
payload: "convenience test payload",
priority: 1,
scope: "scope-C",
});
expect(result.messageId).toBeTruthy();
expect(result.targetAgent).toBeTruthy();
expect(result.reply).toBe(MOCK_REPLY_TEXT);
expect(result.replyMessageId).toBeTruthy();
});
});
// ─── dispatchAndWait — error / no-reply paths ─────────────────────────────────
describe("SwarmDispatchLayer.dispatchAndWait — error paths", () => {
test("returns error when runAgentTurn returns zero turns (empty inbox)", async () => {
// Override mock for this test to simulate an agent that has no messages
// (can happen if the bus is inconsistent). We simulate this by having
// the mock return turnsProcessed=0 after the first call drains the inbox.
const { runAgentTurn } = await import("../uok/agent-runner.js");
// Make runAgentTurn drain the message without writing a reply
runAgentTurn.mockImplementationOnce(async (agent, _opts) => {
const messages = agent.receive(true);
for (const msg of messages) agent.markRead(msg.id);
// Deliberately do NOT write a reply message
return { turnsProcessed: messages.length, response: null };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-no-reply",
unitType: "task",
workMode: "build",
payload: "no reply scenario",
priority: 1,
scope: "scope-D",
});
// No reply message was written, so we expect error="no reply"
expect(result.reply).toBeNull();
expect(result.replyMessageId).toBeNull();
expect(result.error).toBe("no reply");
});
test("returns error when runAgentTurn throws", async () => {
const { runAgentTurn } = await import("../uok/agent-runner.js");
runAgentTurn.mockImplementationOnce(async () => {
throw new Error("LLM timeout");
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-throw",
unitType: "task",
workMode: "build",
payload: "throw scenario",
priority: 1,
scope: "scope-E",
});
expect(result.reply).toBeNull();
expect(result.replyMessageId).toBeNull();
expect(result.error).toContain("LLM timeout");
});
test("returns error struct not throw when runAgentTurn returns error field", async () => {
const { runAgentTurn } = await import("../uok/agent-runner.js");
runAgentTurn.mockImplementationOnce(async () => ({
turnsProcessed: 0,
response: null,
error: "sf headless failed: non-zero exit",
}));
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const result = await layer.dispatchAndWait({
unitId: "task-daw-error-field",
unitType: "task",
workMode: "build",
payload: "error field scenario",
priority: 1,
scope: "scope-F",
});
expect(result.reply).toBeNull();
expect(result.replyMessageId).toBeNull();
expect(result.error).toContain("sf headless failed");
});
});
// ─── Multi-dispatch to the same agent ────────────────────────────────────────
describe("SwarmDispatchLayer.dispatchAndWait — multi-dispatch to same agent", () => {
test("second dispatch to same agent returns correct reply (not null)", async () => {
// This covers Bug 1: the agent's in-memory inbox was stale after the first
// dispatch+turn because INBOX_REFRESH_INTERVAL_MS had not elapsed. The second
// message arrived via a different MessageBus instance (SwarmDispatchLayer._bus)
// and was in SQLite but not in agent._inbox._messages. The fix forces a
// refresh via opts.onlyMessageId before runAgentTurn reads the inbox.
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
// First dispatch — must succeed
const result1 = await layer.dispatchAndWait({
unitId: "task-multi-1",
unitType: "task",
workMode: "build",
payload: "first task",
priority: 5,
scope: "scope-multi",
});
expect(result1.reply).toBe(MOCK_REPLY_TEXT);
expect(result1.error).toBeUndefined();
// Second dispatch to the SAME agent type (build → worker) — must also succeed.
// Before the fix, runAgentTurn would find the agent's inbox stale and return
// turnsProcessed=0, causing getReplyTo to find no reply → error="no reply".
const result2 = await layer.dispatchAndWait({
unitId: "task-multi-2",
unitType: "task",
workMode: "build",
payload: "second task",
priority: 5,
scope: "scope-multi",
});
expect(result2.reply).toBe(MOCK_REPLY_TEXT);
expect(result2.error).toBeUndefined();
expect(result2.replyMessageId).toBeTruthy();
// The two dispatches must have produced different reply message ids
expect(result2.replyMessageId).not.toBe(result1.replyMessageId);
});
test("third dispatch to same agent returns correct reply", async () => {
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
for (let i = 1; i <= 3; i++) {
const result = await layer.dispatchAndWait({
unitId: `task-triple-${i}`,
unitType: "task",
workMode: "build",
payload: `task ${i}`,
priority: 5,
scope: "scope-triple",
});
expect(result.reply).toBe(MOCK_REPLY_TEXT);
expect(result.error).toBeUndefined();
}
});
test("runAgentTurn receives onlyMessageId and does not pick up stale messages", async () => {
// Verify that the onlyMessageId option causes the mock runner to process
// only the target message. The mock in this test file calls agent.receive(false)
// internally, but here we verify the option is forwarded by checking that the
// reply targets the correct messageId via getReplyTo.
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
// Dispatch two tasks to the same agent sequentially
const r1 = await layer.dispatchAndWait({
unitId: "task-isolation-1",
unitType: "task",
workMode: "build",
payload: "isolation task 1",
priority: 5,
scope: "scope-iso",
});
const r2 = await layer.dispatchAndWait({
unitId: "task-isolation-2",
unitType: "task",
workMode: "build",
payload: "isolation task 2",
priority: 5,
scope: "scope-iso",
});
// Both replies are correctly wired to their respective dispatch messageIds
expect(r1.reply).toBe(MOCK_REPLY_TEXT);
expect(r2.reply).toBe(MOCK_REPLY_TEXT);
expect(r1.error).toBeUndefined();
expect(r2.error).toBeUndefined();
});
});
// ─── A2A path — falls through cleanly ────────────────────────────────────────
describe("SwarmDispatchLayer.dispatchAndWait — SF_A2A_ENABLED path", () => {
test("returns null reply and replyMessageId when A2A is enabled", async () => {
// We don't actually start an A2A server; we just verify that dispatchAndWait
// detects SF_A2A_ENABLED and short-circuits with null reply fields without
// calling runAgentTurn. Use a minimal mock for _a2aDispatch.
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
// Stub _a2aDispatch directly so we don't need a real A2A process
const fakeA2aResult = {
messageId: "a2a-msg-001",
targetAgent: "worker-1",
swarmName: "default",
envelope: {},
transport: "a2a",
};
layer._a2aDispatch = vi.fn(async () => fakeA2aResult);
const orig = process.env.SF_A2A_ENABLED;
try {
process.env.SF_A2A_ENABLED = "1";
const result = await layer.dispatchAndWait({
unitId: "task-a2a",
unitType: "task",
workMode: "build",
payload: "a2a payload",
priority: 1,
scope: "scope-a2a",
});
expect(result.reply).toBeNull();
expect(result.replyMessageId).toBeNull();
expect(result.messageId).toBe("a2a-msg-001");
// runAgentTurn must NOT have been called
const { runAgentTurn } = await import("../uok/agent-runner.js");
expect(runAgentTurn).not.toHaveBeenCalled();
} finally {
if (orig === undefined) {
delete process.env.SF_A2A_ENABLED;
} else {
process.env.SF_A2A_ENABLED = orig;
}
}
});
});
// ─── Round 7: executorSystemPrompt / executorTools forwarded from envelope ────
describe("SwarmDispatchLayer.dispatchAndWait — Round 7: executor config forwarding", () => {
test("executorSystemPrompt from envelope is forwarded to runAgentTurn as systemPromptOverride", async () => {
// dispatchAndWait must extract envelope.executorSystemPrompt and pass it as
// opts.systemPromptOverride to runAgentTurn so the swarm worker receives the
// autonomous executor contract (including checkpoint requirement).
const { runAgentTurn } = await import("../uok/agent-runner.js");
let capturedOpts = null;
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
capturedOpts = opts;
const { onlyMessageId } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const EXECUTOR_PROMPT =
"You are an autonomous executor. Call checkpoint when done.";
await layer.dispatchAndWait({
unitId: "task-r7-sys-prompt",
unitType: "execute-task",
workMode: "build",
payload: "do the work",
priority: 5,
scope: "scope-r7",
executorSystemPrompt: EXECUTOR_PROMPT,
});
expect(capturedOpts).not.toBeNull();
expect(capturedOpts.systemPromptOverride).toBe(EXECUTOR_PROMPT);
});
test("executorTools from envelope is forwarded to runAgentTurn as toolsOverride", async () => {
const { runAgentTurn } = await import("../uok/agent-runner.js");
let capturedOpts = null;
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
capturedOpts = opts;
const { onlyMessageId } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const EXECUTOR_TOOLS = ["Read", "Write", "Bash", "checkpoint"];
await layer.dispatchAndWait({
unitId: "task-r7-tools",
unitType: "execute-task",
workMode: "build",
payload: "do the work",
priority: 5,
scope: "scope-r7b",
executorTools: EXECUTOR_TOOLS,
});
expect(capturedOpts).not.toBeNull();
expect(capturedOpts.toolsOverride).toEqual(EXECUTOR_TOOLS);
});
test("executorPermissionLevel from envelope is forwarded to runAgentTurn as permissionLevel", async () => {
const { runAgentTurn } = await import("../uok/agent-runner.js");
let capturedOpts = null;
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
capturedOpts = opts;
const { onlyMessageId } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
await layer.dispatchAndWait({
unitId: "task-r7-permission",
unitType: "execute-task",
workMode: "build",
payload: "edit files",
priority: 5,
scope: "scope-r7-permission",
executorPermissionLevel: "low",
});
expect(capturedOpts).not.toBeNull();
expect(capturedOpts.permissionLevel).toBe("low");
});
test("noOutputTimeoutMs option is forwarded to runAgentTurn", async () => {
const { runAgentTurn } = await import("../uok/agent-runner.js");
let capturedOpts = null;
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
capturedOpts = opts;
const { onlyMessageId } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
await layer.dispatchAndWait(
{
unitId: "task-no-output-timeout",
unitType: "execute-task",
workMode: "build",
payload: "edit files",
priority: 5,
scope: "scope-timeout",
},
{ noOutputTimeoutMs: 45_000 },
);
expect(capturedOpts).not.toBeNull();
expect(capturedOpts.noOutputTimeoutMs).toBe(45_000);
});
test("envelope without executorSystemPrompt does not forward systemPromptOverride", async () => {
// Envelopes without the optional fields must not pass undefined opts to runAgentTurn.
const { runAgentTurn } = await import("../uok/agent-runner.js");
let capturedOpts = null;
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
capturedOpts = opts;
const { onlyMessageId } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
await layer.dispatchAndWait({
unitId: "task-r7-no-override",
unitType: "task",
workMode: "build",
payload: "no override",
priority: 5,
scope: "scope-r7c",
// No executorSystemPrompt or executorTools
});
expect(capturedOpts).not.toBeNull();
expect(capturedOpts.systemPromptOverride).toBeUndefined();
expect(capturedOpts.toolsOverride).toBeUndefined();
expect(capturedOpts.permissionLevel).toBeUndefined();
});
});
// ─── Round 7: checkpoint tool call emitted by worker → outcome propagates ────
describe("SwarmDispatchLayer.dispatchAndWait — Round 7: checkpoint outcome end-to-end", () => {
test("worker emitting checkpoint tool_call with outcome=complete is received via onEvent", async () => {
// End-to-end integration test: the mock runAgentTurn emits a checkpoint
// toolcall_end event via opts.onEvent before returning. The caller's onEvent
// callback must receive it so runUnitViaSwarm can detect outcome=complete.
// (This mirrors what a real runSubagent would do when the worker calls checkpoint.)
const { runAgentTurn } = await import("../uok/agent-runner.js");
const CHECKPOINT_EVENT = {
type: "message_update",
assistantMessageEvent: {
type: "toolcall_end",
contentIndex: 0,
toolCall: {
type: "toolCall",
id: "tc-checkpoint-1",
name: "checkpoint",
arguments: {
outcome: "complete",
summary: "Unit finished successfully.",
completedItems: ["feature done", "tests passing"],
remainingItems: [],
verificationEvidence: ["npm test: green"],
unitType: "execute-task",
unitId: "task-r7-e2e",
pdd: {
purpose: "signal completion",
consumer: "orchestrator",
contract: "outcome=complete",
failureBoundary: "none",
evidence: "tests green",
nonGoals: "n/a",
invariants: "accurate outcome",
assumptions: "tests are definitive",
},
},
},
partial: {},
},
};
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
const { onlyMessageId, onEvent } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
// Emit checkpoint event — simulates a real worker calling the checkpoint tool
if (typeof onEvent === "function") {
onEvent(CHECKPOINT_EVENT);
}
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
"Task completed.",
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: "Task completed.", replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const receivedEvents = [];
const result = await layer.dispatchAndWait(
{
unitId: "task-r7-e2e",
unitType: "execute-task",
workMode: "build",
payload: "implement feature",
priority: 5,
scope: "scope-r7-e2e",
executorSystemPrompt: "You must call checkpoint when done.",
},
{ onEvent: (evt) => receivedEvents.push(evt) },
);
expect(result.reply).toBe("Task completed.");
expect(result.error).toBeUndefined();
// The checkpoint event must have been received by the caller
const checkpointEvent = receivedEvents.find(
(e) =>
e?.assistantMessageEvent?.toolCall?.name === "checkpoint" &&
e?.assistantMessageEvent?.toolCall?.arguments?.outcome === "complete",
);
expect(checkpointEvent).toBeDefined();
});
});
// ─── onEvent propagation through dispatchAndWait ─────────────────────────────
describe("SwarmDispatchLayer.dispatchAndWait — onEvent propagation", () => {
test("onEvent is forwarded to runAgentTurn and events emitted by the mock are received", async () => {
// This test verifies the end-to-end onEvent threading:
// dispatchAndWait(options.onEvent) → runAgentTurn(opts.onEvent) → runHeadlessPrompt(opts.onEvent)
// We override the mock runAgentTurn for this test to call opts.onEvent with a
// synthetic tool-call event, then verify the caller's onEvent received it.
const { runAgentTurn } = await import("../uok/agent-runner.js");
const SYNTHETIC_EVENT = {
type: "message_update",
assistantMessageEvent: {
type: "toolcall_end",
contentIndex: 0,
toolCall: {
type: "toolCall",
id: "tc-001",
name: "checkpoint",
arguments: { outcome: "complete", summary: "Task done" },
},
partial: {},
},
};
// Override: call onEvent with the synthetic event, then write a normal reply.
runAgentTurn.mockImplementationOnce(async (agent, opts = {}) => {
const { onlyMessageId, onEvent } = opts;
if (onlyMessageId) agent._inbox.refresh();
const all = agent.receive(false);
const target = all.find((m) => m.id === onlyMessageId && !m.read);
const messages = target ? [target] : [];
if (messages.length === 0) return { turnsProcessed: 0, response: null };
for (const msg of messages) agent.markRead(msg.id);
// Emit the synthetic event to the caller's onEvent callback
if (typeof onEvent === "function") {
onEvent(SYNTHETIC_EVENT);
}
const lastMsg = messages[messages.length - 1];
const replyId = agent._bus.send(
`agent:${agent.identity.name}`,
lastMsg.from,
MOCK_REPLY_TEXT,
{ replyTo: lastMsg.id, type: "response" },
);
return { turnsProcessed: 1, response: MOCK_REPLY_TEXT, replyId };
});
const root = makeProject();
const layer = new SwarmDispatchLayer(root);
const receivedEvents = [];
const result = await layer.dispatchAndWait(
{
unitId: "task-event-1",
unitType: "task",
workMode: "build",
payload: "propagation test",
priority: 5,
scope: "scope-event",
},
{ onEvent: (event) => receivedEvents.push(event) },
);
expect(result.reply).toBe(MOCK_REPLY_TEXT);
expect(result.error).toBeUndefined();
// The synthetic event must have been received by the caller's onEvent.
expect(receivedEvents).toHaveLength(1);
expect(receivedEvents[0]).toBe(SYNTHETIC_EVENT);
});
});