macro-agent 0.1.12 → 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 (104) 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/team-loader.d.ts.map +1 -1
  64. package/dist/teams/team-loader.js.map +1 -1
  65. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  66. package/dist/teams/team-runtime-v2.js +2 -0
  67. package/dist/teams/team-runtime-v2.js.map +1 -1
  68. package/package.json +6 -5
  69. package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
  70. package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
  71. package/src/agent/agent-manager-v2.ts +268 -8
  72. package/src/agent/types.ts +51 -0
  73. package/src/boot-v2.ts +190 -12
  74. package/src/cli/inbox-mcp-proxy.ts +56 -0
  75. package/src/dispatch/CLAUDE.md +129 -0
  76. package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
  77. package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
  78. package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
  79. package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
  80. package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
  81. package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
  82. package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
  83. package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
  84. package/src/dispatch/loadout-translation.ts +138 -0
  85. package/src/dispatch/mail-inbound-consumer.ts +397 -0
  86. package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
  87. package/src/dispatch/permission-evaluator.ts +191 -0
  88. package/src/dispatch/permission-overlay.ts +89 -0
  89. package/src/dispatch/permissions-handler.ts +112 -0
  90. package/src/dispatch/spawn-agent-handler.ts +160 -0
  91. package/src/lifecycle/handlers-v2.ts +34 -0
  92. package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
  93. package/src/map/__tests__/mail-bridge.test.ts +196 -0
  94. package/src/map/lifecycle-bridge.ts +48 -2
  95. package/src/map/mail-bridge.ts +203 -0
  96. package/src/map/sidecar.ts +346 -1
  97. package/src/map/types.ts +21 -0
  98. package/src/mcp/tools/done-v2.ts +1 -0
  99. package/src/teams/team-loader.ts +3 -1
  100. package/src/teams/team-runtime-v2.ts +2 -0
  101. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  102. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  103. package/dist/workspace/dataplane-adapter.js +0 -416
  104. package/dist/workspace/dataplane-adapter.js.map +0 -1
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Unit tests for createMailInboundReuseConsumer.
3
+ *
4
+ * Mirrors mail-inbound-consumer.test.ts shape but exercises the reuse
5
+ * semantics: drives an existing agent's session via `agentManager.prompt()`
6
+ * (not `spawn` + `promptUntilDone`), tracks `inflightDispatches`, rejects
7
+ * concurrent dispatches with `recipient_busy`, captures done() summary
8
+ * inline from the update stream.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest";
12
+ import {
13
+ getPermissionOverlay,
14
+ _resetForTest as _resetPermissionOverlayForTest,
15
+ _sizeForTest as _permissionOverlaySize,
16
+ } from "../permission-overlay.js";
17
+ import {
18
+ createMailInboundReuseConsumer,
19
+ } from "../mail-inbound-reuse-consumer.js";
20
+ import type {
21
+ InboxEvents,
22
+ InboxMessageEvent,
23
+ MailInboundSidecar,
24
+ } from "../mail-inbound-consumer.js";
25
+ import type { AgentManager } from "../../agent/agent-manager.js";
26
+ import type { AgentStore } from "../../agent/agent-store.js";
27
+
28
+ // ─────────────────────────────────────────────────────────────────
29
+ // Helpers
30
+ // ─────────────────────────────────────────────────────────────────
31
+
32
+ const SIDECAR_ID = "dispatcher:test";
33
+ const TARGET_AGENT_ID = "worker-001";
34
+
35
+ function makeInboxEvents(): InboxEvents & {
36
+ fire(event: InboxMessageEvent): void;
37
+ } {
38
+ let listener: ((e: InboxMessageEvent) => void) | null = null;
39
+ return {
40
+ on(_evt, fn) {
41
+ listener = fn as (e: InboxMessageEvent) => void;
42
+ },
43
+ off(_evt, _fn) {
44
+ listener = null;
45
+ },
46
+ fire(event) {
47
+ listener?.(event);
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Builds an async-iterable mock for `agentManager.prompt()`. The caller
54
+ * supplies the updates and they're yielded one at a time in order.
55
+ */
56
+ function makePromptIterable(updates: unknown[]): AsyncIterable<unknown> {
57
+ return {
58
+ async *[Symbol.asyncIterator]() {
59
+ for (const u of updates) yield u;
60
+ },
61
+ };
62
+ }
63
+
64
+ function makeAgentManager(opts: {
65
+ promptUpdates?: unknown[];
66
+ promptError?: Error;
67
+ /** When set, prompt() returns a never-ending iterable so we can race it. */
68
+ hangPrompt?: boolean;
69
+ }): {
70
+ manager: Partial<AgentManager>;
71
+ promptFn: MockedFunction<AgentManager["prompt"]>;
72
+ } {
73
+ let resolveHang: (() => void) | null = null;
74
+ const promptFn = vi.fn() as unknown as MockedFunction<AgentManager["prompt"]>;
75
+ promptFn.mockImplementation(((_id: string, _msg: string) => {
76
+ if (opts.promptError) {
77
+ // Throw at iteration start.
78
+ return {
79
+ async *[Symbol.asyncIterator]() {
80
+ throw opts.promptError;
81
+ },
82
+ } as unknown as AsyncIterable<any>;
83
+ }
84
+ if (opts.hangPrompt) {
85
+ const hangPromise = new Promise<void>((r) => {
86
+ resolveHang = r;
87
+ });
88
+ return {
89
+ async *[Symbol.asyncIterator]() {
90
+ await hangPromise;
91
+ },
92
+ } as unknown as AsyncIterable<any>;
93
+ }
94
+ return makePromptIterable(opts.promptUpdates ?? []) as unknown as AsyncIterable<any>;
95
+ }) as any);
96
+
97
+ const manager: Partial<AgentManager> = {
98
+ prompt: promptFn,
99
+ };
100
+
101
+ // Expose unblock to caller via mock metadata.
102
+ (manager as any)._releaseHang = () => resolveHang?.();
103
+
104
+ return { manager, promptFn };
105
+ }
106
+
107
+ function makeAgentStore(opts: {
108
+ state?: string;
109
+ notFound?: boolean;
110
+ } = {}): Partial<AgentStore> {
111
+ return {
112
+ getAgent: vi.fn().mockImplementation((id: string) => {
113
+ if (opts.notFound) return null;
114
+ return {
115
+ id,
116
+ state: opts.state ?? "running",
117
+ metadata: {},
118
+ };
119
+ }) as unknown as AgentStore["getAgent"],
120
+ };
121
+ }
122
+
123
+ function makeSidecar(): MailInboundSidecar & {
124
+ postMailTurn: MockedFunction<NonNullable<MailInboundSidecar["postMailTurn"]>>;
125
+ } {
126
+ return {
127
+ postMailTurn: vi.fn().mockResolvedValue(undefined),
128
+ };
129
+ }
130
+
131
+ function workEnvelope(
132
+ taskId: string,
133
+ targetAgentId: string,
134
+ conversationId?: string,
135
+ loadout?: { permissions?: { allow?: string[]; deny?: string[]; ask?: string[] } },
136
+ ): InboxMessageEvent {
137
+ return {
138
+ agentId: targetAgentId,
139
+ message: {
140
+ id: `msg-${taskId}`,
141
+ content: {
142
+ schema: "x-dispatch/work",
143
+ data: {
144
+ taskId,
145
+ prompt: "do the thing",
146
+ role: "worker",
147
+ ...(loadout ? { loadout } : {}),
148
+ },
149
+ ...(conversationId ? { _conversationId: conversationId } : {}),
150
+ },
151
+ },
152
+ };
153
+ }
154
+
155
+ /** A done() tool_call update that captureDoneCall should pick up. */
156
+ function doneUpdate(args: { status: string; summary: string }) {
157
+ return {
158
+ sessionUpdate: "tool_call",
159
+ title: "mcp__macro-agent__done",
160
+ rawInput: args,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Wait for queued microtasks + a tick. The consumer dispatches via void
166
+ * driveDispatch(...) — tests need to let the awaited prompt iterable run.
167
+ */
168
+ async function flushMicrotasks(): Promise<void> {
169
+ await new Promise((resolve) => setImmediate(resolve));
170
+ await new Promise((resolve) => setImmediate(resolve));
171
+ }
172
+
173
+ // ─────────────────────────────────────────────────────────────────
174
+ // Tests
175
+ // ─────────────────────────────────────────────────────────────────
176
+
177
+ describe("createMailInboundReuseConsumer", () => {
178
+ let inboxEvents: ReturnType<typeof makeInboxEvents>;
179
+
180
+ beforeEach(() => {
181
+ inboxEvents = makeInboxEvents();
182
+ _resetPermissionOverlayForTest();
183
+ });
184
+
185
+ it("ignores envelopes addressed to the sidecar (those are owned by mail-inbound-consumer)", async () => {
186
+ const { manager, promptFn } = makeAgentManager({});
187
+ const consumer = createMailInboundReuseConsumer({
188
+ dispatcherAgentId: SIDECAR_ID,
189
+ inboxEvents,
190
+ agentManager: manager as AgentManager,
191
+ agentStore: makeAgentStore() as AgentStore,
192
+ getSidecar: () => makeSidecar(),
193
+ log: () => {},
194
+ });
195
+
196
+ inboxEvents.fire({
197
+ agentId: SIDECAR_ID,
198
+ message: {
199
+ content: {
200
+ schema: "x-dispatch/work",
201
+ data: { taskId: "t-1", prompt: "x" },
202
+ _conversationId: "conv-1",
203
+ },
204
+ },
205
+ });
206
+ await flushMicrotasks();
207
+
208
+ expect(promptFn).not.toHaveBeenCalled();
209
+ consumer.stop();
210
+ });
211
+
212
+ it("ignores non-x-dispatch/work envelopes", async () => {
213
+ const { manager, promptFn } = makeAgentManager({});
214
+ const consumer = createMailInboundReuseConsumer({
215
+ dispatcherAgentId: SIDECAR_ID,
216
+ inboxEvents,
217
+ agentManager: manager as AgentManager,
218
+ agentStore: makeAgentStore() as AgentStore,
219
+ getSidecar: () => makeSidecar(),
220
+ log: () => {},
221
+ });
222
+
223
+ inboxEvents.fire({
224
+ agentId: TARGET_AGENT_ID,
225
+ message: {
226
+ content: { schema: "x-dispatch/cancel", data: { taskId: "t-2" } },
227
+ },
228
+ });
229
+ await flushMicrotasks();
230
+
231
+ expect(promptFn).not.toHaveBeenCalled();
232
+ consumer.stop();
233
+ });
234
+
235
+ it("drives prompt() on the target agent and posts the done() summary back", async () => {
236
+ const { manager, promptFn } = makeAgentManager({
237
+ promptUpdates: [doneUpdate({ status: "completed", summary: "did the thing" })],
238
+ });
239
+ const sidecar = makeSidecar();
240
+ const consumer = createMailInboundReuseConsumer({
241
+ dispatcherAgentId: SIDECAR_ID,
242
+ inboxEvents,
243
+ agentManager: manager as AgentManager,
244
+ agentStore: makeAgentStore() as AgentStore,
245
+ getSidecar: () => sidecar,
246
+ log: () => {},
247
+ });
248
+
249
+ inboxEvents.fire(workEnvelope("t-3", TARGET_AGENT_ID, "conv-3"));
250
+ await flushMicrotasks();
251
+ await flushMicrotasks();
252
+
253
+ expect(promptFn).toHaveBeenCalledTimes(1);
254
+ expect(promptFn).toHaveBeenCalledWith(TARGET_AGENT_ID, "do the thing");
255
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
256
+ "conv-3",
257
+ TARGET_AGENT_ID,
258
+ "did the thing",
259
+ );
260
+ consumer.stop();
261
+ });
262
+
263
+ it("rejects a second dispatch on the same agent with recipient_busy", async () => {
264
+ const { manager, promptFn } = makeAgentManager({ hangPrompt: true });
265
+ const sidecar = makeSidecar();
266
+ const consumer = createMailInboundReuseConsumer({
267
+ dispatcherAgentId: SIDECAR_ID,
268
+ inboxEvents,
269
+ agentManager: manager as AgentManager,
270
+ agentStore: makeAgentStore() as AgentStore,
271
+ getSidecar: () => sidecar,
272
+ log: () => {},
273
+ });
274
+
275
+ // First dispatch — hangs in the prompt iterable.
276
+ inboxEvents.fire(workEnvelope("t-A", TARGET_AGENT_ID, "conv-A"));
277
+ await flushMicrotasks();
278
+
279
+ // Second dispatch arrives while the first is in-flight.
280
+ inboxEvents.fire(workEnvelope("t-B", TARGET_AGENT_ID, "conv-B"));
281
+ await flushMicrotasks();
282
+
283
+ // Only the first prompt was kicked off.
284
+ expect(promptFn).toHaveBeenCalledTimes(1);
285
+ // The second got a recipient_busy reply turn on its conversation.
286
+ const busyCall = sidecar.postMailTurn.mock.calls.find(
287
+ ([conv]) => conv === "conv-B",
288
+ );
289
+ expect(busyCall).toBeDefined();
290
+ expect(busyCall![2]).toContain("recipient_busy");
291
+ expect(busyCall![2]).toContain("t-A");
292
+
293
+ // Stats reflect the busy reject.
294
+ expect(consumer.stats().busyRejects).toBe(1);
295
+ expect(consumer.stats().inflightCount).toBe(1);
296
+
297
+ // Unblock the first prompt so the test cleans up.
298
+ (manager as any)._releaseHang();
299
+ consumer.stop();
300
+ });
301
+
302
+ it("posts agent_unavailable when target is not registered", async () => {
303
+ const { manager, promptFn } = makeAgentManager({});
304
+ const sidecar = makeSidecar();
305
+ const consumer = createMailInboundReuseConsumer({
306
+ dispatcherAgentId: SIDECAR_ID,
307
+ inboxEvents,
308
+ agentManager: manager as AgentManager,
309
+ agentStore: makeAgentStore({ notFound: true }) as AgentStore,
310
+ getSidecar: () => sidecar,
311
+ log: () => {},
312
+ });
313
+
314
+ inboxEvents.fire(workEnvelope("t-4", TARGET_AGENT_ID, "conv-4"));
315
+ await flushMicrotasks();
316
+
317
+ expect(promptFn).not.toHaveBeenCalled();
318
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
319
+ "conv-4",
320
+ TARGET_AGENT_ID,
321
+ expect.stringContaining("agent_unavailable"),
322
+ );
323
+ consumer.stop();
324
+ });
325
+
326
+ it("posts agent_unavailable when target state is stopped", async () => {
327
+ const { manager, promptFn } = makeAgentManager({});
328
+ const sidecar = makeSidecar();
329
+ const consumer = createMailInboundReuseConsumer({
330
+ dispatcherAgentId: SIDECAR_ID,
331
+ inboxEvents,
332
+ agentManager: manager as AgentManager,
333
+ agentStore: makeAgentStore({ state: "stopped" }) as AgentStore,
334
+ getSidecar: () => sidecar,
335
+ log: () => {},
336
+ });
337
+
338
+ inboxEvents.fire(workEnvelope("t-5", TARGET_AGENT_ID, "conv-5"));
339
+ await flushMicrotasks();
340
+
341
+ expect(promptFn).not.toHaveBeenCalled();
342
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
343
+ "conv-5",
344
+ TARGET_AGENT_ID,
345
+ expect.stringContaining("agent_unavailable"),
346
+ );
347
+ consumer.stop();
348
+ });
349
+
350
+ it("posts incomplete reply when prompt cycle ends without a done() call", async () => {
351
+ const { manager, promptFn } = makeAgentManager({
352
+ promptUpdates: [
353
+ { sessionUpdate: "agent_message_chunk", text: "thinking..." },
354
+ ],
355
+ });
356
+ const sidecar = makeSidecar();
357
+ const consumer = createMailInboundReuseConsumer({
358
+ dispatcherAgentId: SIDECAR_ID,
359
+ inboxEvents,
360
+ agentManager: manager as AgentManager,
361
+ agentStore: makeAgentStore() as AgentStore,
362
+ getSidecar: () => sidecar,
363
+ log: () => {},
364
+ });
365
+
366
+ inboxEvents.fire(workEnvelope("t-6", TARGET_AGENT_ID, "conv-6"));
367
+ await flushMicrotasks();
368
+ await flushMicrotasks();
369
+
370
+ expect(promptFn).toHaveBeenCalledTimes(1);
371
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
372
+ "conv-6",
373
+ TARGET_AGENT_ID,
374
+ expect.stringContaining("incomplete"),
375
+ );
376
+ consumer.stop();
377
+ });
378
+
379
+ it("dedups re-deliveries of the same taskId within the TTL window", async () => {
380
+ const { manager, promptFn } = makeAgentManager({
381
+ promptUpdates: [doneUpdate({ status: "completed", summary: "ok" })],
382
+ });
383
+ const sidecar = makeSidecar();
384
+ const consumer = createMailInboundReuseConsumer({
385
+ dispatcherAgentId: SIDECAR_ID,
386
+ inboxEvents,
387
+ agentManager: manager as AgentManager,
388
+ agentStore: makeAgentStore() as AgentStore,
389
+ getSidecar: () => sidecar,
390
+ log: () => {},
391
+ });
392
+
393
+ inboxEvents.fire(workEnvelope("t-7", TARGET_AGENT_ID, "conv-7"));
394
+ await flushMicrotasks();
395
+ await flushMicrotasks();
396
+ inboxEvents.fire(workEnvelope("t-7", TARGET_AGENT_ID, "conv-7"));
397
+ await flushMicrotasks();
398
+
399
+ expect(promptFn).toHaveBeenCalledTimes(1);
400
+ consumer.stop();
401
+ });
402
+
403
+ it("releases the inflight slot when prompt() throws", async () => {
404
+ const { manager } = makeAgentManager({
405
+ promptError: new Error("transport gone"),
406
+ });
407
+ const sidecar = makeSidecar();
408
+ const consumer = createMailInboundReuseConsumer({
409
+ dispatcherAgentId: SIDECAR_ID,
410
+ inboxEvents,
411
+ agentManager: manager as AgentManager,
412
+ agentStore: makeAgentStore() as AgentStore,
413
+ getSidecar: () => sidecar,
414
+ log: () => {},
415
+ });
416
+
417
+ inboxEvents.fire(workEnvelope("t-8", TARGET_AGENT_ID, "conv-8"));
418
+ await flushMicrotasks();
419
+ await flushMicrotasks();
420
+
421
+ expect(consumer.stats().inflightCount).toBe(0);
422
+ // failed reply posted
423
+ expect(sidecar.postMailTurn).toHaveBeenCalledWith(
424
+ "conv-8",
425
+ TARGET_AGENT_ID,
426
+ expect.stringContaining("failed"),
427
+ );
428
+ consumer.stop();
429
+ });
430
+
431
+ it("applies a permission overlay when the envelope's loadout carries deny rules", async () => {
432
+ let overlayDuringPrompt: unknown = null;
433
+ // Capture the overlay at the moment prompt() is called.
434
+ const promptUpdates = [
435
+ doneUpdate({ status: "completed", summary: "ok" }),
436
+ ];
437
+ const promptFn = vi.fn() as unknown as MockedFunction<AgentManager["prompt"]>;
438
+ promptFn.mockImplementation(((id: string) => {
439
+ // Read overlay synchronously at the call site.
440
+ overlayDuringPrompt = getPermissionOverlay(id);
441
+ return {
442
+ async *[Symbol.asyncIterator]() {
443
+ for (const u of promptUpdates) yield u;
444
+ },
445
+ } as unknown as AsyncIterable<any>;
446
+ }) as any);
447
+ const manager: Partial<AgentManager> = { prompt: promptFn };
448
+
449
+ const sidecar = makeSidecar();
450
+ const consumer = createMailInboundReuseConsumer({
451
+ dispatcherAgentId: SIDECAR_ID,
452
+ inboxEvents,
453
+ agentManager: manager as AgentManager,
454
+ agentStore: makeAgentStore() as AgentStore,
455
+ getSidecar: () => sidecar,
456
+ log: () => {},
457
+ });
458
+
459
+ const loadout = {
460
+ permissions: {
461
+ deny: ["Bash(echo perm-deny-test:*)"],
462
+ },
463
+ };
464
+ inboxEvents.fire(workEnvelope("t-overlay", TARGET_AGENT_ID, "conv-overlay", loadout));
465
+ await flushMicrotasks();
466
+ await flushMicrotasks();
467
+
468
+ // During prompt: overlay was set with the loadout's deny rule.
469
+ expect(overlayDuringPrompt).toEqual({
470
+ allow: [],
471
+ deny: ["Bash(echo perm-deny-test:*)"],
472
+ });
473
+
474
+ // After driveDispatch's finally fires, overlay is cleared.
475
+ expect(getPermissionOverlay(TARGET_AGENT_ID)).toBeUndefined();
476
+ expect(_permissionOverlaySize()).toBe(0);
477
+ consumer.stop();
478
+ });
479
+
480
+ it("collapses ask rules to allow under fullAutonomous=true (mail-inbound default)", async () => {
481
+ let overlayDuringPrompt: unknown = null;
482
+ const promptFn = vi.fn() as unknown as MockedFunction<AgentManager["prompt"]>;
483
+ promptFn.mockImplementation(((id: string) => {
484
+ overlayDuringPrompt = getPermissionOverlay(id);
485
+ return {
486
+ async *[Symbol.asyncIterator]() {
487
+ yield doneUpdate({ status: "completed", summary: "ok" });
488
+ },
489
+ } as unknown as AsyncIterable<any>;
490
+ }) as any);
491
+ const manager: Partial<AgentManager> = { prompt: promptFn };
492
+
493
+ const consumer = createMailInboundReuseConsumer({
494
+ dispatcherAgentId: SIDECAR_ID,
495
+ inboxEvents,
496
+ agentManager: manager as AgentManager,
497
+ agentStore: makeAgentStore() as AgentStore,
498
+ getSidecar: () => makeSidecar(),
499
+ log: () => {},
500
+ });
501
+
502
+ const loadout = {
503
+ permissions: {
504
+ allow: ["Read(**)"],
505
+ ask: ["Write(.env)"],
506
+ },
507
+ };
508
+ inboxEvents.fire(workEnvelope("t-collapse", TARGET_AGENT_ID, "conv-c", loadout));
509
+ await flushMicrotasks();
510
+ await flushMicrotasks();
511
+
512
+ // ask rules collapsed to allow because mail-inbound workers are autonomous.
513
+ expect(overlayDuringPrompt).toEqual({
514
+ allow: ["Read(**)", "Write(.env)"],
515
+ deny: [],
516
+ });
517
+ consumer.stop();
518
+ });
519
+
520
+ it("does NOT set an overlay when the envelope has no loadout permissions", async () => {
521
+ let overlayDuringPrompt: unknown = "uninspected";
522
+ const promptFn = vi.fn() as unknown as MockedFunction<AgentManager["prompt"]>;
523
+ promptFn.mockImplementation(((id: string) => {
524
+ overlayDuringPrompt = getPermissionOverlay(id);
525
+ return {
526
+ async *[Symbol.asyncIterator]() {
527
+ yield doneUpdate({ status: "completed", summary: "ok" });
528
+ },
529
+ } as unknown as AsyncIterable<any>;
530
+ }) as any);
531
+ const manager: Partial<AgentManager> = { prompt: promptFn };
532
+
533
+ const consumer = createMailInboundReuseConsumer({
534
+ dispatcherAgentId: SIDECAR_ID,
535
+ inboxEvents,
536
+ agentManager: manager as AgentManager,
537
+ agentStore: makeAgentStore() as AgentStore,
538
+ getSidecar: () => makeSidecar(),
539
+ log: () => {},
540
+ });
541
+
542
+ inboxEvents.fire(workEnvelope("t-noloadout", TARGET_AGENT_ID, "conv-n"));
543
+ await flushMicrotasks();
544
+ await flushMicrotasks();
545
+
546
+ expect(overlayDuringPrompt).toBeUndefined();
547
+ consumer.stop();
548
+ });
549
+
550
+ it("clears the overlay even when prompt() throws (defense-in-depth)", async () => {
551
+ const manager = makeAgentManager({
552
+ promptError: new Error("transport gone"),
553
+ });
554
+ const consumer = createMailInboundReuseConsumer({
555
+ dispatcherAgentId: SIDECAR_ID,
556
+ inboxEvents,
557
+ agentManager: manager.manager as AgentManager,
558
+ agentStore: makeAgentStore() as AgentStore,
559
+ getSidecar: () => makeSidecar(),
560
+ log: () => {},
561
+ });
562
+
563
+ const loadout = {
564
+ permissions: { deny: ["Bash(*)"] },
565
+ };
566
+ inboxEvents.fire(workEnvelope("t-throw", TARGET_AGENT_ID, "conv-throw", loadout));
567
+ await flushMicrotasks();
568
+ await flushMicrotasks();
569
+
570
+ // Prompt threw → overlay cleared, no leak.
571
+ expect(getPermissionOverlay(TARGET_AGENT_ID)).toBeUndefined();
572
+ expect(_permissionOverlaySize()).toBe(0);
573
+ consumer.stop();
574
+ });
575
+ });