macro-agent 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  2. package/dist/agent/agent-manager-v2.js +240 -7
  3. package/dist/agent/agent-manager-v2.js.map +1 -1
  4. package/dist/agent/types.d.ts +47 -0
  5. package/dist/agent/types.d.ts.map +1 -1
  6. package/dist/agent/types.js.map +1 -1
  7. package/dist/boot-v2.d.ts +33 -0
  8. package/dist/boot-v2.d.ts.map +1 -1
  9. package/dist/boot-v2.js +142 -11
  10. package/dist/boot-v2.js.map +1 -1
  11. package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
  12. package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
  13. package/dist/cli/inbox-mcp-proxy.js +51 -0
  14. package/dist/cli/inbox-mcp-proxy.js.map +1 -0
  15. package/dist/dispatch/loadout-translation.d.ts +100 -0
  16. package/dist/dispatch/loadout-translation.d.ts.map +1 -0
  17. package/dist/dispatch/loadout-translation.js +90 -0
  18. package/dist/dispatch/loadout-translation.js.map +1 -0
  19. package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
  20. package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
  21. package/dist/dispatch/mail-inbound-consumer.js +261 -0
  22. package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
  23. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
  24. package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
  25. package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
  26. package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
  27. package/dist/dispatch/permission-evaluator.d.ts +68 -0
  28. package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
  29. package/dist/dispatch/permission-evaluator.js +159 -0
  30. package/dist/dispatch/permission-evaluator.js.map +1 -0
  31. package/dist/dispatch/permission-overlay.d.ts +64 -0
  32. package/dist/dispatch/permission-overlay.d.ts.map +1 -0
  33. package/dist/dispatch/permission-overlay.js +72 -0
  34. package/dist/dispatch/permission-overlay.js.map +1 -0
  35. package/dist/dispatch/permissions-handler.d.ts +71 -0
  36. package/dist/dispatch/permissions-handler.d.ts.map +1 -0
  37. package/dist/dispatch/permissions-handler.js +83 -0
  38. package/dist/dispatch/permissions-handler.js.map +1 -0
  39. package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
  40. package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
  41. package/dist/dispatch/spawn-agent-handler.js +85 -0
  42. package/dist/dispatch/spawn-agent-handler.js.map +1 -0
  43. package/dist/lifecycle/handlers-v2.d.ts +7 -0
  44. package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
  45. package/dist/lifecycle/handlers-v2.js +27 -0
  46. package/dist/lifecycle/handlers-v2.js.map +1 -1
  47. package/dist/map/lifecycle-bridge.d.ts +18 -0
  48. package/dist/map/lifecycle-bridge.d.ts.map +1 -1
  49. package/dist/map/lifecycle-bridge.js +23 -1
  50. package/dist/map/lifecycle-bridge.js.map +1 -1
  51. package/dist/map/mail-bridge.d.ts +55 -0
  52. package/dist/map/mail-bridge.d.ts.map +1 -0
  53. package/dist/map/mail-bridge.js +115 -0
  54. package/dist/map/mail-bridge.js.map +1 -0
  55. package/dist/map/sidecar.d.ts.map +1 -1
  56. package/dist/map/sidecar.js +245 -1
  57. package/dist/map/sidecar.js.map +1 -1
  58. package/dist/map/types.d.ts +15 -0
  59. package/dist/map/types.d.ts.map +1 -1
  60. package/dist/mcp/tools/done-v2.d.ts.map +1 -1
  61. package/dist/mcp/tools/done-v2.js +1 -0
  62. package/dist/mcp/tools/done-v2.js.map +1 -1
  63. package/dist/teams/seed-defaults.d.ts.map +1 -1
  64. package/dist/teams/seed-defaults.js +6 -2
  65. package/dist/teams/seed-defaults.js.map +1 -1
  66. package/dist/teams/team-loader.d.ts.map +1 -1
  67. package/dist/teams/team-loader.js +17 -1
  68. package/dist/teams/team-loader.js.map +1 -1
  69. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  70. package/dist/teams/team-runtime-v2.js +2 -0
  71. package/dist/teams/team-runtime-v2.js.map +1 -1
  72. package/package.json +6 -6
  73. package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
  74. package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
  75. package/src/agent/agent-manager-v2.ts +268 -8
  76. package/src/agent/types.ts +51 -0
  77. package/src/boot-v2.ts +190 -12
  78. package/src/cli/inbox-mcp-proxy.ts +56 -0
  79. package/src/dispatch/CLAUDE.md +129 -0
  80. package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
  81. package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
  82. package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
  83. package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
  84. package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
  85. package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
  86. package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
  87. package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
  88. package/src/dispatch/loadout-translation.ts +138 -0
  89. package/src/dispatch/mail-inbound-consumer.ts +397 -0
  90. package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
  91. package/src/dispatch/permission-evaluator.ts +191 -0
  92. package/src/dispatch/permission-overlay.ts +89 -0
  93. package/src/dispatch/permissions-handler.ts +112 -0
  94. package/src/dispatch/spawn-agent-handler.ts +160 -0
  95. package/src/lifecycle/handlers-v2.ts +34 -0
  96. package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
  97. package/src/map/__tests__/mail-bridge.test.ts +196 -0
  98. package/src/map/lifecycle-bridge.ts +48 -2
  99. package/src/map/mail-bridge.ts +203 -0
  100. package/src/map/sidecar.ts +346 -1
  101. package/src/map/types.ts +21 -0
  102. package/src/mcp/tools/done-v2.ts +1 -0
  103. package/src/teams/seed-defaults.ts +6 -2
  104. package/src/teams/team-loader.ts +21 -2
  105. package/src/teams/team-runtime-v2.ts +2 -0
  106. package/src/workspace/__tests__/self-driving-yaml.test.ts +10 -2
  107. package/templates/teams/self-driving/team.yaml +142 -0
  108. package/tsconfig.json +2 -1
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Integration tests for the mail-bridge → mail-inbound-consumer chain.
3
+ *
4
+ * What these tests verify that the mocked unit tests cannot:
5
+ * - The real `setupMailBridge` fires `inboxAdapter.send` with the correct
6
+ * shape (type:"data", schema, _conversationId) when a hub notification arrives.
7
+ * - That send() outcome flows into the real `createMailInboundConsumer`'s
8
+ * inbox.message listener — i.e. the two modules compose correctly.
9
+ * - Deduplication across N re-deliveries of the same taskId.
10
+ * - The "type: data missing" regression: if a future maintainer removes the
11
+ * explicit `type: "data"` field from mail-bridge, the consumer's schema
12
+ * check still classifies because the bridge restores it before send().
13
+ *
14
+ * Real modules used:
15
+ * - setupMailBridge (map/mail-bridge.ts)
16
+ * - createMailInboundConsumer (dispatch/mail-inbound-consumer.ts)
17
+ *
18
+ * Everything else is faked via minimal in-process objects — no SQLite, no
19
+ * IPC sockets, no subprocesses.
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest";
23
+ import { setupMailBridge, type MailBridgeConnection } from "../../map/mail-bridge.js";
24
+ import {
25
+ createMailInboundConsumer,
26
+ type InboxEvents,
27
+ type InboxMessageEvent,
28
+ type MailInboundSidecar,
29
+ } from "../mail-inbound-consumer.js";
30
+ import type { AgentManager } from "../../agent/agent-manager.js";
31
+ import type { AgentStore } from "../../agent/agent-store.js";
32
+
33
+ // ─────────────────────────────────────────────────────────────────
34
+ // Fake InboxAdapter that acts as the glue between bridge and consumer.
35
+ //
36
+ // The bridge calls inboxAdapter.send(from, to, content, opts).
37
+ // The consumer listens on inboxEvents (which is inboxAdapter.getInbox().events).
38
+ //
39
+ // In production, DefaultInboxAdapter routes the send() call through
40
+ // agent-inbox's router, which then fires inbox.events("inbox.message").
41
+ // Here we short-circuit that: our fake send() directly fires the event
42
+ // so we don't need SQLite or IPC.
43
+ // ─────────────────────────────────────────────────────────────────
44
+
45
+ type MessageListener = (event: InboxMessageEvent) => void;
46
+
47
+ function buildFakeInboxAdapter(dispatcherAgentId: string) {
48
+ const listeners = new Set<MessageListener>();
49
+
50
+ // InboxEvents interface for the consumer
51
+ const inboxEvents: InboxEvents = {
52
+ on(_evt: string, fn: MessageListener) {
53
+ listeners.add(fn);
54
+ },
55
+ off(_evt: string, fn: MessageListener) {
56
+ listeners.delete(fn);
57
+ },
58
+ };
59
+
60
+ const sendSpy = vi.fn(
61
+ async (
62
+ from: string,
63
+ to: string,
64
+ content: Record<string, unknown>,
65
+ _opts?: unknown,
66
+ ): Promise<string> => {
67
+ // Short-circuit: fire the inbox.message event directly so the consumer
68
+ // receives what the bridge sends — this is the key integration coupling.
69
+ const event: InboxMessageEvent = {
70
+ agentId: to,
71
+ message: {
72
+ id: `synthetic-${Math.random()}`,
73
+ content,
74
+ sender_id: from,
75
+ },
76
+ };
77
+ for (const fn of listeners) fn(event);
78
+ return "msg-synthetic";
79
+ },
80
+ );
81
+
82
+ const registerAgentSpy = vi.fn().mockResolvedValue(undefined);
83
+
84
+ const inboxAdapter = {
85
+ registerAgent: registerAgentSpy,
86
+ send: sendSpy,
87
+ };
88
+
89
+ return { inboxAdapter, inboxEvents, sendSpy, registerAgentSpy };
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────
93
+ // Fake MAP connection — captures notifications and lets tests fire them
94
+ // ─────────────────────────────────────────────────────────────────
95
+
96
+ function buildFakeConnection(): MailBridgeConnection & {
97
+ sendNotification: MockedFunction<(method: string, params: unknown) => void>;
98
+ _fire: (params: unknown) => Promise<void>;
99
+ offNotification: ReturnType<typeof vi.fn>;
100
+ } {
101
+ let mailTurnHandler: ((params: unknown) => void | Promise<void>) | null = null;
102
+
103
+ const sendNotification = vi.fn();
104
+ const offNotification = vi.fn();
105
+
106
+ return {
107
+ onNotification(method: string, handler: (params: unknown) => void | Promise<void>) {
108
+ if (method === "mail/turn.received") mailTurnHandler = handler;
109
+ },
110
+ offNotification,
111
+ sendNotification,
112
+ async _fire(params: unknown) {
113
+ if (mailTurnHandler) await mailTurnHandler(params);
114
+ },
115
+ };
116
+ }
117
+
118
+ // ─────────────────────────────────────────────────────────────────
119
+ // Fake AgentManager — captures spawns and exposes lifecycle firing
120
+ // ─────────────────────────────────────────────────────────────────
121
+
122
+ function buildFakeAgentManager(spawnedId = "agent-001") {
123
+ const lifecycleListeners: Array<(e: { type: string; agent: { id: string }; reason: string }) => void> = [];
124
+
125
+ const spawnFn = vi.fn().mockResolvedValue({ id: spawnedId });
126
+
127
+ const manager: Partial<AgentManager> = {
128
+ spawn: spawnFn as unknown as AgentManager["spawn"],
129
+ onLifecycleEvent(cb) {
130
+ lifecycleListeners.push(cb as never);
131
+ return () => {
132
+ const idx = lifecycleListeners.indexOf(cb as never);
133
+ if (idx >= 0) lifecycleListeners.splice(idx, 1);
134
+ };
135
+ },
136
+ };
137
+
138
+ function fireLifecycle(e: { type: string; agent: { id: string }; reason: string }) {
139
+ for (const fn of lifecycleListeners) fn(e);
140
+ }
141
+
142
+ return { manager, spawnFn, fireLifecycle };
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────
146
+ // Fake AgentStore
147
+ // ─────────────────────────────────────────────────────────────────
148
+
149
+ function buildFakeAgentStore(summary?: string): Partial<AgentStore> {
150
+ return {
151
+ getAgent: vi.fn().mockReturnValue(
152
+ summary !== undefined
153
+ ? { metadata: { _lastSummary: summary } }
154
+ : { metadata: {} },
155
+ ),
156
+ };
157
+ }
158
+
159
+ // ─────────────────────────────────────────────────────────────────
160
+ // Fake sidecar
161
+ // ─────────────────────────────────────────────────────────────────
162
+
163
+ function buildFakeSidecar(): MailInboundSidecar & {
164
+ postMailTurn: MockedFunction<NonNullable<MailInboundSidecar["postMailTurn"]>>;
165
+ } {
166
+ return { postMailTurn: vi.fn().mockResolvedValue(undefined) };
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────
170
+ // Helper: build a hub envelope matching what OpenHive sends
171
+ // ─────────────────────────────────────────────────────────────────
172
+
173
+ function hubNotification(opts: {
174
+ conversationId: string;
175
+ taskId: string;
176
+ prompt: string;
177
+ participantId?: string;
178
+ }) {
179
+ return {
180
+ conversation_id: opts.conversationId,
181
+ turn_id: `turn-${Math.random().toString(36).slice(2)}`,
182
+ participant_id: opts.participantId ?? "openhive:dispatcher",
183
+ content_type: "application/json",
184
+ content: JSON.stringify({
185
+ type: "x-dispatch/work",
186
+ body: {
187
+ taskId: opts.taskId,
188
+ prompt: opts.prompt,
189
+ role: "worker",
190
+ },
191
+ }),
192
+ };
193
+ }
194
+
195
+ // ─────────────────────────────────────────────────────────────────
196
+ // Constants
197
+ // ─────────────────────────────────────────────────────────────────
198
+
199
+ const DISPATCHER_ID = "dispatcher:integration-test:1234:abc";
200
+
201
+ // ─────────────────────────────────────────────────────────────────
202
+ // Tests
203
+ // ─────────────────────────────────────────────────────────────────
204
+
205
+ describe("mail-bridge → mail-inbound-consumer integration", () => {
206
+ let conn: ReturnType<typeof buildFakeConnection>;
207
+ let inboxAdapter: ReturnType<typeof buildFakeInboxAdapter>["inboxAdapter"];
208
+ let inboxEvents: InboxEvents;
209
+ let sendSpy: ReturnType<typeof buildFakeInboxAdapter>["sendSpy"];
210
+ let am: ReturnType<typeof buildFakeAgentManager>;
211
+ let store: Partial<AgentStore>;
212
+ let sidecar: ReturnType<typeof buildFakeSidecar>;
213
+
214
+ beforeEach(() => {
215
+ conn = buildFakeConnection();
216
+ const fake = buildFakeInboxAdapter(DISPATCHER_ID);
217
+ inboxAdapter = fake.inboxAdapter;
218
+ inboxEvents = fake.inboxEvents;
219
+ sendSpy = fake.sendSpy;
220
+ am = buildFakeAgentManager("agent-001");
221
+ store = buildFakeAgentStore("WIDGET_SENTINEL_42 hello");
222
+ sidecar = buildFakeSidecar();
223
+ });
224
+
225
+ // ── Test 1: Happy path end-to-end ─────────────────────────────
226
+ it(
227
+ "drives hub notification through bridge into consumer: spawn then postMailTurn",
228
+ async () => {
229
+ // Wire the real bridge
230
+ await setupMailBridge({
231
+ connection: conn,
232
+ inboxAdapter: inboxAdapter as never,
233
+ dispatcherAgentId: DISPATCHER_ID,
234
+ });
235
+
236
+ // Wire the real consumer
237
+ createMailInboundConsumer({
238
+ dispatcherAgentId: DISPATCHER_ID,
239
+ inboxEvents,
240
+ agentManager: am.manager as AgentManager,
241
+ agentStore: store as AgentStore,
242
+ getSidecar: () => sidecar,
243
+ });
244
+
245
+ const CONV_ID = "conv-integration-001";
246
+ const TASK_ID = "task-widget-42";
247
+
248
+ // Fire the hub notification — this is what OpenHive sends over MAP
249
+ await conn._fire(hubNotification({ conversationId: CONV_ID, taskId: TASK_ID, prompt: "Build widget" }));
250
+
251
+ // 1. Bridge must have called inboxAdapter.send once
252
+ expect(sendSpy).toHaveBeenCalledOnce();
253
+
254
+ const [from, to, content, opts] = sendSpy.mock.calls[0];
255
+ expect(from).toBe("openhive:dispatcher");
256
+ expect(to).toBe(DISPATCHER_ID);
257
+
258
+ // 2. Content must have type:"data", correct schema, _conversationId
259
+ expect(content).toMatchObject({
260
+ type: "data",
261
+ schema: "x-dispatch/work",
262
+ data: {
263
+ taskId: TASK_ID,
264
+ prompt: "Build widget",
265
+ role: "worker",
266
+ },
267
+ _conversationId: CONV_ID,
268
+ });
269
+ // Verify legacy 'body' key is NOT present (bridge translated it)
270
+ expect(content).not.toHaveProperty("body");
271
+
272
+ // 3. Allow the async spawn to settle
273
+ await new Promise((r) => setTimeout(r, 20));
274
+
275
+ // 4. Consumer must have spawned one worker with correct params
276
+ expect(am.spawnFn).toHaveBeenCalledOnce();
277
+ expect(am.spawnFn).toHaveBeenCalledWith(
278
+ expect.objectContaining({
279
+ task: "Build widget",
280
+ task_id: TASK_ID,
281
+ role: "worker",
282
+ parent: null,
283
+ }),
284
+ );
285
+
286
+ // 5. Fire lifecycle stopped for the spawned agent
287
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
288
+ await new Promise((r) => setTimeout(r, 20));
289
+
290
+ // 6. Consumer must have called postMailTurn with original convId + sentinel
291
+ expect(sidecar.postMailTurn).toHaveBeenCalledOnce();
292
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
293
+ CONV_ID,
294
+ "agent-001",
295
+ "WIDGET_SENTINEL_42 hello",
296
+ );
297
+ },
298
+ );
299
+
300
+ // ── Test 2: Deduplication under N re-deliveries ───────────────
301
+ it(
302
+ "deduplicates 50 re-deliveries of the same taskId: spawn and postMailTurn called only once",
303
+ async () => {
304
+ await setupMailBridge({
305
+ connection: conn,
306
+ inboxAdapter: inboxAdapter as never,
307
+ dispatcherAgentId: DISPATCHER_ID,
308
+ });
309
+
310
+ createMailInboundConsumer({
311
+ dispatcherAgentId: DISPATCHER_ID,
312
+ inboxEvents,
313
+ agentManager: am.manager as AgentManager,
314
+ agentStore: store as AgentStore,
315
+ getSidecar: () => sidecar,
316
+ });
317
+
318
+ const CONV_ID = "conv-dedup-test";
319
+ const TASK_ID = "task-dedup-99";
320
+ const notification = hubNotification({ conversationId: CONV_ID, taskId: TASK_ID, prompt: "Dedup me" });
321
+
322
+ // Fire 50 identical notifications (same taskId)
323
+ const fires: Promise<void>[] = [];
324
+ for (let i = 0; i < 50; i++) {
325
+ fires.push(conn._fire(notification));
326
+ }
327
+ await Promise.all(fires);
328
+ await new Promise((r) => setTimeout(r, 50));
329
+
330
+ // Bridge sends 50 times (it's not the bridge's job to dedup — that's the consumer)
331
+ expect(sendSpy).toHaveBeenCalledTimes(50);
332
+
333
+ // Consumer dedupes: spawn called exactly once
334
+ expect(am.spawnFn).toHaveBeenCalledTimes(1);
335
+
336
+ // Fire stopped for the single spawned agent
337
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
338
+ await new Promise((r) => setTimeout(r, 20));
339
+
340
+ // postMailTurn called exactly once
341
+ expect(sidecar.postMailTurn).toHaveBeenCalledTimes(1);
342
+ },
343
+ );
344
+
345
+ // ── Test 3: Regression guard — type:"data" must be present ────
346
+ it(
347
+ "bridge always adds type:'data' so consumer classifies the schema correctly",
348
+ async () => {
349
+ // This test guards the bug described in the mail-bridge comment:
350
+ // "Without `type: "data"`, agent-inbox's normalizeContent wraps the
351
+ // object as { type:"data", data: original }, burying `schema` one
352
+ // level deeper and breaking createMailInboundConsumer's filter."
353
+ //
354
+ // We verify: even if someone sends a payload WITHOUT `type` (e.g., a
355
+ // hub that sends canonical {schema, data} already), the bridge still
356
+ // produces a content object with top-level `type: "data"` so
357
+ // normalizeContent passes it through without re-wrapping.
358
+
359
+ await setupMailBridge({
360
+ connection: conn,
361
+ inboxAdapter: inboxAdapter as never,
362
+ dispatcherAgentId: DISPATCHER_ID,
363
+ });
364
+
365
+ createMailInboundConsumer({
366
+ dispatcherAgentId: DISPATCHER_ID,
367
+ inboxEvents,
368
+ agentManager: am.manager as AgentManager,
369
+ agentStore: store as AgentStore,
370
+ getSidecar: () => sidecar,
371
+ });
372
+
373
+ // Send a payload that already uses the canonical {schema, data} shape
374
+ // (no 'type' key, no 'body' key) — this is the "pass-through" path in
375
+ // setupMailBridge (hubType is undefined, falls through to raw).
376
+ await conn._fire({
377
+ conversation_id: "conv-regression-001",
378
+ turn_id: "turn-regression-001",
379
+ participant_id: "openhive:dispatcher",
380
+ content_type: "application/json",
381
+ content: JSON.stringify({
382
+ schema: "x-dispatch/work",
383
+ data: { taskId: "task-regression-001", prompt: "Regression check", role: "worker" },
384
+ }),
385
+ });
386
+
387
+ await new Promise((r) => setTimeout(r, 20));
388
+
389
+ // Bridge must have sent with type:"data" present at the top level
390
+ expect(sendSpy).toHaveBeenCalledOnce();
391
+ const [, , content] = sendSpy.mock.calls[0];
392
+ expect(content).toHaveProperty("type", "data");
393
+ expect(content).toHaveProperty("schema", "x-dispatch/work");
394
+ // schema must be at top level, NOT buried under data
395
+ expect((content as Record<string, unknown>).data).not.toHaveProperty("schema");
396
+
397
+ // Consumer must have spawned (proves schema was accessible at top level)
398
+ expect(am.spawnFn).toHaveBeenCalledOnce();
399
+ expect(am.spawnFn).toHaveBeenCalledWith(
400
+ expect.objectContaining({
401
+ task: "Regression check",
402
+ task_id: "task-regression-001",
403
+ }),
404
+ );
405
+ },
406
+ );
407
+
408
+ it(
409
+ "echo-loop containment: when the worker's reply turn is echoed back via mail/turn.received, the bridge drops it without spawning a second worker",
410
+ async () => {
411
+ // Reproduces the runaway-spawn scenario the hub-side echo would
412
+ // create absent the bridge's plain-text drop:
413
+ // 1. Worker A processes dispatch, calls done() with summary.
414
+ // 2. Consumer posts the summary back via mapSidecar.postMailTurn.
415
+ // 3. Hub fires mail.turn.added → forwardTurnToSwarms re-fires the
416
+ // same conversation back to the swarm as mail/turn.received.
417
+ // 4. Bridge MUST classify the plain-text content as non-JSON and
418
+ // drop it; the consumer MUST NOT see it as a new dispatch and
419
+ // spawn worker B.
420
+ const { inboxAdapter, inboxEvents, sendSpy } = buildFakeInboxAdapter("dispatcher-echo");
421
+ const conn = buildFakeConnection();
422
+ const am = buildFakeAgentManager("agent-echo");
423
+ const store = buildFakeAgentStore();
424
+ const sidecar = buildFakeSidecar();
425
+
426
+ await setupMailBridge({
427
+ connection: conn,
428
+ inboxAdapter: inboxAdapter as never,
429
+ dispatcherAgentId: "dispatcher-echo",
430
+ });
431
+ createMailInboundConsumer({
432
+ dispatcherAgentId: "dispatcher-echo",
433
+ inboxEvents,
434
+ agentManager: am.manager as AgentManager,
435
+ agentStore: store as AgentStore,
436
+ getSidecar: () => sidecar,
437
+ });
438
+
439
+ // First, deliver a real dispatch so we have a non-zero baseline.
440
+ await conn._fire({
441
+ conversation_id: "conv-echo-001",
442
+ turn_id: "turn-echo-001",
443
+ participant_id: "openhive:dispatcher",
444
+ content_type: "application/json",
445
+ content: JSON.stringify({
446
+ type: "x-dispatch/work",
447
+ body: { taskId: "task-echo-001", prompt: "do thing", role: "worker" },
448
+ }),
449
+ });
450
+ await new Promise((r) => setTimeout(r, 10));
451
+ expect(am.spawnFn).toHaveBeenCalledTimes(1);
452
+
453
+ // Now simulate the echo: hub re-fires mail/turn.received with the
454
+ // worker's plain-text reply. content_type matches what postMailTurn
455
+ // sets; content is a plain string starting with the SENTINEL prefix.
456
+ await conn._fire({
457
+ conversation_id: "conv-echo-001",
458
+ turn_id: "turn-echo-002-reply",
459
+ participant_id: "agent-echo",
460
+ content_type: "text/plain",
461
+ content: "WIDGET_SENTINEL_42 Hello! Worker task acknowledged and completed.",
462
+ });
463
+ await new Promise((r) => setTimeout(r, 10));
464
+
465
+ // Bridge dropped the echo: inbox.send was NOT called for the reply.
466
+ // (sendSpy was called once for the original dispatch envelope, not
467
+ // again for the echoed text reply.)
468
+ expect(sendSpy).toHaveBeenCalledTimes(1);
469
+
470
+ // Consumer MUST NOT have spawned a second worker.
471
+ expect(am.spawnFn).toHaveBeenCalledTimes(1);
472
+ },
473
+ );
474
+
475
+ it(
476
+ "echo-loop containment: a malformed JSON payload (looks like JSON but isn't a dispatch envelope) does not spawn a worker",
477
+ async () => {
478
+ // Defensive: even if the echoed content happens to be JSON-shaped
479
+ // (e.g. an agent that wraps its reply as a JSON message), the
480
+ // consumer must reject it because schema !== "x-dispatch/work".
481
+ const { inboxAdapter, inboxEvents, sendSpy } = buildFakeInboxAdapter("dispatcher-echo-2");
482
+ const conn = buildFakeConnection();
483
+ const am = buildFakeAgentManager("agent-echo-2");
484
+ const store = buildFakeAgentStore();
485
+ const sidecar = buildFakeSidecar();
486
+
487
+ await setupMailBridge({
488
+ connection: conn,
489
+ inboxAdapter: inboxAdapter as never,
490
+ dispatcherAgentId: "dispatcher-echo-2",
491
+ });
492
+ createMailInboundConsumer({
493
+ dispatcherAgentId: "dispatcher-echo-2",
494
+ inboxEvents,
495
+ agentManager: am.manager as AgentManager,
496
+ agentStore: store as AgentStore,
497
+ getSidecar: () => sidecar,
498
+ });
499
+
500
+ // Echo containing a JSON payload but with a different schema.
501
+ await conn._fire({
502
+ conversation_id: "conv-fake-echo",
503
+ turn_id: "turn-fake-echo",
504
+ participant_id: "agent-echo-2",
505
+ content_type: "application/json",
506
+ content: JSON.stringify({
507
+ schema: "x-agent/reply",
508
+ data: { text: "WIDGET_SENTINEL_42 some reply" },
509
+ }),
510
+ });
511
+ await new Promise((r) => setTimeout(r, 10));
512
+
513
+ // Bridge forwarded once (it doesn't classify by schema), but the
514
+ // consumer's onMessage filter (schema === "x-dispatch/work") rejects.
515
+ expect(sendSpy).toHaveBeenCalledOnce();
516
+ expect(am.spawnFn).not.toHaveBeenCalled();
517
+ },
518
+ );
519
+ });