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.
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +240 -7
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/types.d.ts +47 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +33 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +142 -11
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
- package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
- package/dist/cli/inbox-mcp-proxy.js +51 -0
- package/dist/cli/inbox-mcp-proxy.js.map +1 -0
- package/dist/dispatch/loadout-translation.d.ts +100 -0
- package/dist/dispatch/loadout-translation.d.ts.map +1 -0
- package/dist/dispatch/loadout-translation.js +90 -0
- package/dist/dispatch/loadout-translation.js.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.js +261 -0
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
- package/dist/dispatch/permission-evaluator.d.ts +68 -0
- package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
- package/dist/dispatch/permission-evaluator.js +159 -0
- package/dist/dispatch/permission-evaluator.js.map +1 -0
- package/dist/dispatch/permission-overlay.d.ts +64 -0
- package/dist/dispatch/permission-overlay.d.ts.map +1 -0
- package/dist/dispatch/permission-overlay.js +72 -0
- package/dist/dispatch/permission-overlay.js.map +1 -0
- package/dist/dispatch/permissions-handler.d.ts +71 -0
- package/dist/dispatch/permissions-handler.d.ts.map +1 -0
- package/dist/dispatch/permissions-handler.js +83 -0
- package/dist/dispatch/permissions-handler.js.map +1 -0
- package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
- package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
- package/dist/dispatch/spawn-agent-handler.js +85 -0
- package/dist/dispatch/spawn-agent-handler.js.map +1 -0
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +27 -0
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/map/lifecycle-bridge.d.ts +18 -0
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +23 -1
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +55 -0
- package/dist/map/mail-bridge.d.ts.map +1 -0
- package/dist/map/mail-bridge.js +115 -0
- package/dist/map/mail-bridge.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +245 -1
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +15 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +1 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +2 -0
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +6 -5
- package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
- package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
- package/src/agent/agent-manager-v2.ts +268 -8
- package/src/agent/types.ts +51 -0
- package/src/boot-v2.ts +190 -12
- package/src/cli/inbox-mcp-proxy.ts +56 -0
- package/src/dispatch/CLAUDE.md +129 -0
- package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
- package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
- package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
- package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
- package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
- package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
- package/src/dispatch/loadout-translation.ts +138 -0
- package/src/dispatch/mail-inbound-consumer.ts +397 -0
- package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
- package/src/dispatch/permission-evaluator.ts +191 -0
- package/src/dispatch/permission-overlay.ts +89 -0
- package/src/dispatch/permissions-handler.ts +112 -0
- package/src/dispatch/spawn-agent-handler.ts +160 -0
- package/src/lifecycle/handlers-v2.ts +34 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
- package/src/map/__tests__/mail-bridge.test.ts +196 -0
- package/src/map/lifecycle-bridge.ts +48 -2
- package/src/map/mail-bridge.ts +203 -0
- package/src/map/sidecar.ts +346 -1
- package/src/map/types.ts +21 -0
- package/src/mcp/tools/done-v2.ts +1 -0
- package/src/teams/team-loader.ts +3 -1
- package/src/teams/team-runtime-v2.ts +2 -0
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- package/dist/workspace/dataplane-adapter.js.map +0 -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
|
+
});
|