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,589 @@
1
+ /**
2
+ * Unit tests for createMailInboundConsumer.
3
+ *
4
+ * All dependencies are mocked — no real inbox, agentManager, or sidecar.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest";
8
+ import {
9
+ createMailInboundConsumer,
10
+ type InboxEvents,
11
+ type InboxMessageEvent,
12
+ type MailInboundSidecar,
13
+ } from "../mail-inbound-consumer.js";
14
+ import type { AgentManager } from "../../agent/agent-manager.js";
15
+ import type { AgentStore } from "../../agent/agent-store.js";
16
+
17
+ // ─────────────────────────────────────────────────────────────────
18
+ // Test helpers
19
+ // ─────────────────────────────────────────────────────────────────
20
+
21
+ function makeInboxEvents(): InboxEvents & {
22
+ fire(event: InboxMessageEvent): void;
23
+ } {
24
+ let listener: ((e: InboxMessageEvent) => void) | null = null;
25
+ return {
26
+ on(_evt, fn) {
27
+ listener = fn as (e: InboxMessageEvent) => void;
28
+ },
29
+ off(_evt, _fn) {
30
+ listener = null;
31
+ },
32
+ fire(event) {
33
+ listener?.(event);
34
+ },
35
+ };
36
+ }
37
+
38
+ function makeAgentManager(spawnedId = "agent-001"): {
39
+ manager: Partial<AgentManager>;
40
+ spawnFn: MockedFunction<AgentManager["spawn"]>;
41
+ lifecycleListeners: Array<(e: { type: string; agent: { id: string }; reason: string }) => void>;
42
+ fireLifecycle(e: { type: string; agent: { id: string }; reason: string }): void;
43
+ } {
44
+ const lifecycleListeners: Array<
45
+ (e: { type: string; agent: { id: string }; reason: string }) => void
46
+ > = [];
47
+
48
+ const spawnFn = vi.fn().mockResolvedValue({ id: spawnedId }) as unknown as MockedFunction<
49
+ AgentManager["spawn"]
50
+ >;
51
+
52
+ const manager: Partial<AgentManager> = {
53
+ spawn: spawnFn,
54
+ onLifecycleEvent(cb) {
55
+ lifecycleListeners.push(cb as any);
56
+ return () => {
57
+ const idx = lifecycleListeners.indexOf(cb as any);
58
+ if (idx >= 0) lifecycleListeners.splice(idx, 1);
59
+ };
60
+ },
61
+ };
62
+
63
+ return {
64
+ manager,
65
+ spawnFn,
66
+ lifecycleListeners,
67
+ fireLifecycle(e) {
68
+ for (const fn of lifecycleListeners) fn(e);
69
+ },
70
+ };
71
+ }
72
+
73
+ function makeAgentStore(summary?: string): Partial<AgentStore> {
74
+ return {
75
+ getAgent: vi.fn().mockReturnValue(
76
+ summary !== undefined
77
+ ? { metadata: { _lastSummary: summary } }
78
+ : { metadata: {} },
79
+ ),
80
+ };
81
+ }
82
+
83
+ function makeSidecar(): MailInboundSidecar & {
84
+ postMailTurn: MockedFunction<NonNullable<MailInboundSidecar["postMailTurn"]>>;
85
+ } {
86
+ return {
87
+ postMailTurn: vi.fn().mockResolvedValue(undefined),
88
+ };
89
+ }
90
+
91
+ function workEnvelope(
92
+ taskId: string,
93
+ prompt: string,
94
+ conversationId?: string,
95
+ ): InboxMessageEvent {
96
+ return {
97
+ agentId: "dispatcher:test",
98
+ message: {
99
+ id: `msg-${taskId}`,
100
+ content: {
101
+ schema: "x-dispatch/work",
102
+ data: { taskId, prompt, role: "worker" },
103
+ ...(conversationId ? { _conversationId: conversationId } : {}),
104
+ },
105
+ },
106
+ };
107
+ }
108
+
109
+ // ─────────────────────────────────────────────────────────────────
110
+ // Tests
111
+ // ─────────────────────────────────────────────────────────────────
112
+
113
+ const DISPATCHER_ID = "dispatcher:test";
114
+
115
+ describe("createMailInboundConsumer", () => {
116
+ let inboxEvents: ReturnType<typeof makeInboxEvents>;
117
+ let am: ReturnType<typeof makeAgentManager>;
118
+ let store: ReturnType<typeof makeAgentStore>;
119
+ let sidecar: ReturnType<typeof makeSidecar>;
120
+
121
+ beforeEach(() => {
122
+ inboxEvents = makeInboxEvents();
123
+ am = makeAgentManager("agent-001");
124
+ store = makeAgentStore();
125
+ sidecar = makeSidecar();
126
+ });
127
+
128
+ it("logs ready message on creation", () => {
129
+ const logs: string[] = [];
130
+ createMailInboundConsumer({
131
+ dispatcherAgentId: DISPATCHER_ID,
132
+ inboxEvents,
133
+ agentManager: am.manager as AgentManager,
134
+ agentStore: store as AgentStore,
135
+ getSidecar: () => sidecar,
136
+ log: (m) => logs.push(m),
137
+ });
138
+ expect(logs.some((l) => l.includes("Consumer ready"))).toBe(true);
139
+ expect(logs.some((l) => l.includes(DISPATCHER_ID))).toBe(true);
140
+ });
141
+
142
+ it("ignores inbox messages for other agent IDs", async () => {
143
+ createMailInboundConsumer({
144
+ dispatcherAgentId: DISPATCHER_ID,
145
+ inboxEvents,
146
+ agentManager: am.manager as AgentManager,
147
+ agentStore: store as AgentStore,
148
+ getSidecar: () => sidecar,
149
+ });
150
+
151
+ // fire an event for a DIFFERENT recipient
152
+ inboxEvents.fire({
153
+ agentId: "some-other-agent",
154
+ message: {
155
+ content: { schema: "x-dispatch/work", data: { taskId: "t1", prompt: "do it" } },
156
+ },
157
+ });
158
+
159
+ await new Promise((r) => setTimeout(r, 10));
160
+ expect(am.spawnFn).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("ignores non-x-dispatch/work schema messages", async () => {
164
+ createMailInboundConsumer({
165
+ dispatcherAgentId: DISPATCHER_ID,
166
+ inboxEvents,
167
+ agentManager: am.manager as AgentManager,
168
+ agentStore: store as AgentStore,
169
+ getSidecar: () => sidecar,
170
+ });
171
+
172
+ inboxEvents.fire({
173
+ agentId: DISPATCHER_ID,
174
+ message: { content: { schema: "some-other-schema", data: {} } },
175
+ });
176
+
177
+ await new Promise((r) => setTimeout(r, 10));
178
+ expect(am.spawnFn).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("spawns a worker agent when x-dispatch/work arrives", async () => {
182
+ createMailInboundConsumer({
183
+ dispatcherAgentId: DISPATCHER_ID,
184
+ inboxEvents,
185
+ agentManager: am.manager as AgentManager,
186
+ agentStore: store as AgentStore,
187
+ getSidecar: () => sidecar,
188
+ });
189
+
190
+ inboxEvents.fire(workEnvelope("task-42", "Build the widget", "conv-99"));
191
+
192
+ // Allow microtask / spawn promise to settle
193
+ await new Promise((r) => setTimeout(r, 20));
194
+
195
+ expect(am.spawnFn).toHaveBeenCalledOnce();
196
+ expect(am.spawnFn).toHaveBeenCalledWith(
197
+ expect.objectContaining({
198
+ task: "Build the widget",
199
+ task_id: "task-42",
200
+ role: "worker",
201
+ parent: null,
202
+ }),
203
+ );
204
+ });
205
+
206
+ it("calls postMailTurn with conversationId + summary when worker stops", async () => {
207
+ store = makeAgentStore("WIDGET_SENTINEL_42: done");
208
+ sidecar = makeSidecar();
209
+
210
+ createMailInboundConsumer({
211
+ dispatcherAgentId: DISPATCHER_ID,
212
+ inboxEvents,
213
+ agentManager: am.manager as AgentManager,
214
+ agentStore: store as AgentStore,
215
+ getSidecar: () => sidecar,
216
+ });
217
+
218
+ inboxEvents.fire(workEnvelope("task-42", "Build widget", "conv-99"));
219
+ await new Promise((r) => setTimeout(r, 20));
220
+
221
+ // Simulate agent stopping with "completed"
222
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
223
+ await new Promise((r) => setTimeout(r, 20));
224
+
225
+ expect(sidecar.postMailTurn).toHaveBeenCalledOnce();
226
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
227
+ "conv-99",
228
+ "agent-001",
229
+ "WIDGET_SENTINEL_42: done",
230
+ );
231
+ });
232
+
233
+ it("does not call postMailTurn when _lastSummary is missing", async () => {
234
+ store = makeAgentStore(undefined);
235
+ sidecar = makeSidecar();
236
+
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
+ inboxEvents.fire(workEnvelope("task-X", "Do work", "conv-X"));
246
+ await new Promise((r) => setTimeout(r, 20));
247
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
248
+ await new Promise((r) => setTimeout(r, 10));
249
+
250
+ expect(sidecar.postMailTurn).not.toHaveBeenCalled();
251
+ });
252
+
253
+ it("does not call postMailTurn when envelope has no conversationId", async () => {
254
+ store = makeAgentStore("some summary");
255
+ sidecar = makeSidecar();
256
+
257
+ createMailInboundConsumer({
258
+ dispatcherAgentId: DISPATCHER_ID,
259
+ inboxEvents,
260
+ agentManager: am.manager as AgentManager,
261
+ agentStore: store as AgentStore,
262
+ getSidecar: () => sidecar,
263
+ });
264
+
265
+ // No conversationId in this envelope
266
+ inboxEvents.fire(workEnvelope("task-Y", "Do work"));
267
+ await new Promise((r) => setTimeout(r, 20));
268
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
269
+ await new Promise((r) => setTimeout(r, 10));
270
+
271
+ expect(sidecar.postMailTurn).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("ignores stopped events for agents it did not spawn", async () => {
275
+ createMailInboundConsumer({
276
+ dispatcherAgentId: DISPATCHER_ID,
277
+ inboxEvents,
278
+ agentManager: am.manager as AgentManager,
279
+ agentStore: store as AgentStore,
280
+ getSidecar: () => sidecar,
281
+ });
282
+
283
+ // Fire stopped for an unknown agent — should not touch sidecar
284
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-unknown" }, reason: "completed" });
285
+ await new Promise((r) => setTimeout(r, 10));
286
+
287
+ expect(sidecar.postMailTurn).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("stop() detaches inbox listener and lifecycle subscription", async () => {
291
+ const logs: string[] = [];
292
+ const consumer = createMailInboundConsumer({
293
+ dispatcherAgentId: DISPATCHER_ID,
294
+ inboxEvents,
295
+ agentManager: am.manager as AgentManager,
296
+ agentStore: store as AgentStore,
297
+ getSidecar: () => sidecar,
298
+ log: (m) => logs.push(m),
299
+ });
300
+
301
+ consumer.stop();
302
+
303
+ // After stop, inbox messages should be ignored
304
+ inboxEvents.fire(workEnvelope("task-Z", "after stop", "conv-Z"));
305
+ await new Promise((r) => setTimeout(r, 20));
306
+
307
+ expect(am.spawnFn).not.toHaveBeenCalled();
308
+ expect(logs.some((l) => l.includes("Consumer stopped"))).toBe(true);
309
+ });
310
+
311
+ // ── Failure-mode coverage ─────────────────────────────────────────
312
+
313
+ it("forwards summary unchanged when it lacks the expected sentinel — consumer is not a content validator", async () => {
314
+ // The consumer's job is mechanical relay: whatever the worker put in
315
+ // _lastSummary is what gets posted back. Content-level checks (e.g.,
316
+ // "must start with WIDGET_SENTINEL_42") belong to the dispatch
317
+ // initiator's verifier, not to the consumer.
318
+ store = makeAgentStore("plain reply with no sentinel");
319
+ sidecar = makeSidecar();
320
+
321
+ createMailInboundConsumer({
322
+ dispatcherAgentId: DISPATCHER_ID,
323
+ inboxEvents,
324
+ agentManager: am.manager as AgentManager,
325
+ agentStore: store as AgentStore,
326
+ getSidecar: () => sidecar,
327
+ });
328
+
329
+ inboxEvents.fire(workEnvelope("task-no-sentinel", "go", "conv-no-sentinel"));
330
+ await new Promise((r) => setTimeout(r, 20));
331
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
332
+ await new Promise((r) => setTimeout(r, 10));
333
+
334
+ expect(sidecar.postMailTurn).toHaveBeenCalledOnce();
335
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
336
+ "conv-no-sentinel",
337
+ "agent-001",
338
+ "plain reply with no sentinel",
339
+ );
340
+ });
341
+
342
+ it("agentManager.spawn() rejecting does not crash the consumer — error is logged", async () => {
343
+ const logs: string[] = [];
344
+ am.spawnFn.mockRejectedValueOnce(new Error("acp-factory: handshake timeout"));
345
+
346
+ createMailInboundConsumer({
347
+ dispatcherAgentId: DISPATCHER_ID,
348
+ inboxEvents,
349
+ agentManager: am.manager as AgentManager,
350
+ agentStore: store as AgentStore,
351
+ getSidecar: () => sidecar,
352
+ log: (m) => logs.push(m),
353
+ });
354
+
355
+ inboxEvents.fire(workEnvelope("task-spawn-fails", "go", "conv-X"));
356
+ await new Promise((r) => setTimeout(r, 20));
357
+
358
+ // Spawn was attempted, failed, was logged, and the consumer is still alive.
359
+ expect(am.spawnFn).toHaveBeenCalledOnce();
360
+ expect(logs.some((l) => l.includes("Spawn failed for taskId=task-spawn-fails"))).toBe(true);
361
+ expect(logs.some((l) => l.includes("handshake timeout"))).toBe(true);
362
+
363
+ // No reply posted, since no agent ever stopped (we never fired lifecycle).
364
+ expect(sidecar.postMailTurn).not.toHaveBeenCalled();
365
+
366
+ // Consumer still functional — a subsequent dispatch goes through.
367
+ am.spawnFn.mockResolvedValueOnce({ id: "agent-after-fail" } as never);
368
+ inboxEvents.fire(workEnvelope("task-after-fail", "go", "conv-Y"));
369
+ await new Promise((r) => setTimeout(r, 10));
370
+ expect(am.spawnFn).toHaveBeenCalledTimes(2);
371
+ });
372
+
373
+ it("clears _lastSummary on the agent record after successful postMailTurn", async () => {
374
+ // Prevents a stale summary from re-firing if the same agentId is ever
375
+ // reused for another dispatch (defensive — the manager normally mints
376
+ // fresh ids, but this is cheap insurance).
377
+ store = makeAgentStore("WIDGET_SENTINEL_42 done");
378
+ const updateAgentSpy = vi.fn();
379
+ (store as { updateAgent?: typeof updateAgentSpy }).updateAgent = updateAgentSpy;
380
+ sidecar = makeSidecar();
381
+
382
+ createMailInboundConsumer({
383
+ dispatcherAgentId: DISPATCHER_ID,
384
+ inboxEvents,
385
+ agentManager: am.manager as AgentManager,
386
+ agentStore: store as AgentStore,
387
+ getSidecar: () => sidecar,
388
+ });
389
+
390
+ inboxEvents.fire(workEnvelope("task-clear", "go", "conv-clear"));
391
+ await new Promise((r) => setTimeout(r, 20));
392
+ am.fireLifecycle({ type: "stopped", agent: { id: "agent-001" }, reason: "completed" });
393
+ await new Promise((r) => setTimeout(r, 20));
394
+
395
+ expect(sidecar.postMailTurn).toHaveBeenCalledOnce();
396
+ expect(updateAgentSpy).toHaveBeenCalledWith(
397
+ "agent-001",
398
+ { metadata: expect.not.objectContaining({ _lastSummary: expect.anything() }) },
399
+ );
400
+ });
401
+
402
+ it("dedup TTL: a re-delivered taskId can spawn again after the dedup window expires", async () => {
403
+ vi.useFakeTimers();
404
+ try {
405
+ createMailInboundConsumer({
406
+ dispatcherAgentId: DISPATCHER_ID,
407
+ inboxEvents,
408
+ agentManager: am.manager as AgentManager,
409
+ agentStore: store as AgentStore,
410
+ getSidecar: () => sidecar,
411
+ });
412
+
413
+ inboxEvents.fire(workEnvelope("task-ttl", "go", "conv-ttl"));
414
+ await Promise.resolve();
415
+ expect(am.spawnFn).toHaveBeenCalledTimes(1);
416
+
417
+ // Same taskId within the dedup window → drop
418
+ inboxEvents.fire(workEnvelope("task-ttl", "go", "conv-ttl"));
419
+ await Promise.resolve();
420
+ expect(am.spawnFn).toHaveBeenCalledTimes(1);
421
+
422
+ // Past the TTL (1h)
423
+ vi.advanceTimersByTime(60 * 60 * 1000 + 1_000);
424
+
425
+ // Re-fire — now allowed because the dedup entry expired
426
+ inboxEvents.fire(workEnvelope("task-ttl", "go", "conv-ttl"));
427
+ await Promise.resolve();
428
+ expect(am.spawnFn).toHaveBeenCalledTimes(2);
429
+ } finally {
430
+ vi.useRealTimers();
431
+ }
432
+ });
433
+
434
+ // ── Loadout / permission coverage ────────────────────────────────
435
+
436
+ describe("loadout permissions wiring", () => {
437
+ function envelopeWith(
438
+ taskId: string,
439
+ data: Record<string, unknown>,
440
+ conversationId = "conv-loadout",
441
+ ): InboxMessageEvent {
442
+ return {
443
+ agentId: DISPATCHER_ID,
444
+ message: {
445
+ id: `msg-${taskId}`,
446
+ content: {
447
+ schema: "x-dispatch/work",
448
+ data: { taskId, prompt: "go", role: "worker", ...data },
449
+ _conversationId: conversationId,
450
+ },
451
+ },
452
+ };
453
+ }
454
+
455
+ it("data.loadout (canonical) → spawn called with permissions + fullAutonomous: true", async () => {
456
+ createMailInboundConsumer({
457
+ dispatcherAgentId: DISPATCHER_ID,
458
+ inboxEvents,
459
+ agentManager: am.manager as AgentManager,
460
+ agentStore: store as AgentStore,
461
+ getSidecar: () => sidecar,
462
+ });
463
+
464
+ inboxEvents.fire(
465
+ envelopeWith("task-loadout-1", {
466
+ loadout: { permissions: { deny: ["Bash(rm -rf:*)"] } },
467
+ }),
468
+ );
469
+ await new Promise((r) => setTimeout(r, 20));
470
+
471
+ expect(am.spawnFn).toHaveBeenCalledOnce();
472
+ const args = am.spawnFn.mock.calls[0][0];
473
+ expect(args.permissions).toEqual({
474
+ allow: [],
475
+ deny: ["Bash(rm -rf:*)"],
476
+ ask: [],
477
+ });
478
+ expect(args.fullAutonomous).toBe(true);
479
+ });
480
+
481
+ it("legacy data.metadata.permissions (no data.loadout) → spawn called with permissions + fullAutonomous: true", async () => {
482
+ createMailInboundConsumer({
483
+ dispatcherAgentId: DISPATCHER_ID,
484
+ inboxEvents,
485
+ agentManager: am.manager as AgentManager,
486
+ agentStore: store as AgentStore,
487
+ getSidecar: () => sidecar,
488
+ });
489
+
490
+ inboxEvents.fire(
491
+ envelopeWith("task-legacy-1", {
492
+ metadata: { permissions: { deny: ["Bash(rm -rf:*)"] } },
493
+ }),
494
+ );
495
+ await new Promise((r) => setTimeout(r, 20));
496
+
497
+ expect(am.spawnFn).toHaveBeenCalledOnce();
498
+ const args = am.spawnFn.mock.calls[0][0];
499
+ expect(args.permissions).toEqual({
500
+ allow: [],
501
+ deny: ["Bash(rm -rf:*)"],
502
+ ask: [],
503
+ });
504
+ expect(args.fullAutonomous).toBe(true);
505
+ });
506
+
507
+ it("data.loadout wins over data.metadata.permissions when both are present", async () => {
508
+ createMailInboundConsumer({
509
+ dispatcherAgentId: DISPATCHER_ID,
510
+ inboxEvents,
511
+ agentManager: am.manager as AgentManager,
512
+ agentStore: store as AgentStore,
513
+ getSidecar: () => sidecar,
514
+ });
515
+
516
+ inboxEvents.fire(
517
+ envelopeWith("task-both-1", {
518
+ // canonical
519
+ loadout: { permissions: { deny: ["Bash(canonical:*)"] } },
520
+ // legacy — should be ignored because canonical is present
521
+ metadata: { permissions: { deny: ["Bash(legacy:*)"] } },
522
+ }),
523
+ );
524
+ await new Promise((r) => setTimeout(r, 20));
525
+
526
+ expect(am.spawnFn).toHaveBeenCalledOnce();
527
+ const args = am.spawnFn.mock.calls[0][0];
528
+ expect(args.permissions).toEqual({
529
+ allow: [],
530
+ deny: ["Bash(canonical:*)"],
531
+ ask: [],
532
+ });
533
+ expect(args.fullAutonomous).toBe(true);
534
+ });
535
+
536
+ it("envelope with neither data.loadout nor data.metadata.permissions → spawn called WITHOUT permissions", async () => {
537
+ createMailInboundConsumer({
538
+ dispatcherAgentId: DISPATCHER_ID,
539
+ inboxEvents,
540
+ agentManager: am.manager as AgentManager,
541
+ agentStore: store as AgentStore,
542
+ getSidecar: () => sidecar,
543
+ });
544
+
545
+ inboxEvents.fire(envelopeWith("task-bare", {}));
546
+ await new Promise((r) => setTimeout(r, 20));
547
+
548
+ expect(am.spawnFn).toHaveBeenCalledOnce();
549
+ const args = am.spawnFn.mock.calls[0][0];
550
+ expect(args.permissions).toBeUndefined();
551
+ expect(args.fullAutonomous).toBeUndefined();
552
+ });
553
+ });
554
+
555
+ it("stats(): malformed-envelope counter increments and seenTaskIds reflects current state", async () => {
556
+ const consumer = createMailInboundConsumer({
557
+ dispatcherAgentId: DISPATCHER_ID,
558
+ inboxEvents,
559
+ agentManager: am.manager as AgentManager,
560
+ agentStore: store as AgentStore,
561
+ getSidecar: () => sidecar,
562
+ });
563
+
564
+ expect(consumer.stats()).toEqual({ droppedMalformed: 0, seenTaskIds: 0 });
565
+
566
+ // Malformed: schema is x-dispatch/work but data has no taskId
567
+ inboxEvents.fire({
568
+ agentId: DISPATCHER_ID,
569
+ message: {
570
+ id: "malformed-1",
571
+ content: { schema: "x-dispatch/work", data: { prompt: "no id" } },
572
+ },
573
+ });
574
+ inboxEvents.fire({
575
+ agentId: DISPATCHER_ID,
576
+ message: {
577
+ id: "malformed-2",
578
+ content: { schema: "x-dispatch/work", data: {} },
579
+ },
580
+ });
581
+ expect(consumer.stats()).toEqual({ droppedMalformed: 2, seenTaskIds: 0 });
582
+
583
+ // Well-formed envelope adds to seenTaskIds
584
+ inboxEvents.fire(workEnvelope("task-stats", "go", "conv-stats"));
585
+ await Promise.resolve();
586
+ expect(consumer.stats().seenTaskIds).toBe(1);
587
+ expect(consumer.stats().droppedMalformed).toBe(2);
588
+ });
589
+ });