opencode-swarm-plugin 0.25.2 → 0.25.3
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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +20 -0
- package/dist/beads.d.ts +6 -0
- package/dist/beads.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +192 -287
- package/dist/plugin.js +191 -292
- package/dist/rate-limiter.d.ts.map +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/swarm-orchestrate.d.ts.map +1 -1
- package/docs/swarm-mail-architecture.md +1 -1
- package/package.json +2 -2
- package/src/beads.integration.test.ts +55 -61
- package/src/beads.ts +239 -410
- package/src/index.ts +1 -15
- package/src/rate-limiter.ts +0 -5
- package/src/storage.ts +0 -9
- package/src/swarm-mail.integration.test.ts +5 -1
- package/src/swarm-orchestrate.ts +24 -14
- package/src/swarm.integration.test.ts +83 -0
- package/src/agent-mail.integration.test.ts +0 -1429
|
@@ -1,1429 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for agent-mail.ts
|
|
3
|
-
*
|
|
4
|
-
* These tests run against a real Agent Mail server (typically in Docker).
|
|
5
|
-
* Set AGENT_MAIL_URL environment variable to override the default server location.
|
|
6
|
-
*
|
|
7
|
-
* Run with: pnpm test:integration
|
|
8
|
-
* Or in Docker: docker compose up --build --abort-on-container-exit
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, it, expect, beforeAll } from "vitest";
|
|
12
|
-
import {
|
|
13
|
-
mcpCall,
|
|
14
|
-
mcpCallWithAutoInit,
|
|
15
|
-
sessionStates,
|
|
16
|
-
setState,
|
|
17
|
-
clearState,
|
|
18
|
-
requireState,
|
|
19
|
-
MAX_INBOX_LIMIT,
|
|
20
|
-
AgentMailNotInitializedError,
|
|
21
|
-
isProjectNotFoundError,
|
|
22
|
-
isAgentNotFoundError,
|
|
23
|
-
type AgentMailState,
|
|
24
|
-
} from "./agent-mail";
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// Test Configuration
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
const AGENT_MAIL_URL = process.env.AGENT_MAIL_URL || "http://127.0.0.1:8765";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Generate a unique test context to avoid state collisions between tests
|
|
34
|
-
*/
|
|
35
|
-
function createTestContext() {
|
|
36
|
-
const id = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
37
|
-
return {
|
|
38
|
-
sessionID: id,
|
|
39
|
-
projectKey: `/test/project-${id}`,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Initialize a test agent and return its state
|
|
45
|
-
*/
|
|
46
|
-
async function initTestAgent(
|
|
47
|
-
ctx: { sessionID: string; projectKey: string },
|
|
48
|
-
agentName?: string,
|
|
49
|
-
) {
|
|
50
|
-
// Ensure project exists
|
|
51
|
-
const project = await mcpCall<{
|
|
52
|
-
id: number;
|
|
53
|
-
slug: string;
|
|
54
|
-
human_key: string;
|
|
55
|
-
}>("ensure_project", { human_key: ctx.projectKey });
|
|
56
|
-
|
|
57
|
-
// Register agent
|
|
58
|
-
const agent = await mcpCall<{
|
|
59
|
-
id: number;
|
|
60
|
-
name: string;
|
|
61
|
-
program: string;
|
|
62
|
-
model: string;
|
|
63
|
-
task_description: string;
|
|
64
|
-
}>("register_agent", {
|
|
65
|
-
project_key: ctx.projectKey,
|
|
66
|
-
program: "opencode-test",
|
|
67
|
-
model: "test-model",
|
|
68
|
-
name: agentName,
|
|
69
|
-
task_description: "Integration test agent",
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Store state
|
|
73
|
-
const state: AgentMailState = {
|
|
74
|
-
projectKey: ctx.projectKey,
|
|
75
|
-
agentName: agent.name,
|
|
76
|
-
reservations: [],
|
|
77
|
-
startedAt: new Date().toISOString(),
|
|
78
|
-
};
|
|
79
|
-
setState(ctx.sessionID, state);
|
|
80
|
-
|
|
81
|
-
return { project, agent, state };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ============================================================================
|
|
85
|
-
// Health Check Tests
|
|
86
|
-
// ============================================================================
|
|
87
|
-
|
|
88
|
-
describe("agent-mail integration", () => {
|
|
89
|
-
beforeAll(async () => {
|
|
90
|
-
// Verify server is reachable before running tests
|
|
91
|
-
const response = await fetch(`${AGENT_MAIL_URL}/health/liveness`);
|
|
92
|
-
if (!response.ok) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
`Agent Mail server not available at ${AGENT_MAIL_URL}. ` +
|
|
95
|
-
`Start it with: docker compose up agent-mail`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("agentmail_health", () => {
|
|
101
|
-
it("returns healthy when server is running", async () => {
|
|
102
|
-
const response = await fetch(`${AGENT_MAIL_URL}/health/liveness`);
|
|
103
|
-
expect(response.ok).toBe(true);
|
|
104
|
-
const data = await response.json();
|
|
105
|
-
// Real Agent Mail returns "alive" not "ok"
|
|
106
|
-
expect(data.status).toBe("alive");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("returns ready when database is accessible", async () => {
|
|
110
|
-
const response = await fetch(`${AGENT_MAIL_URL}/health/readiness`);
|
|
111
|
-
expect(response.ok).toBe(true);
|
|
112
|
-
const data = await response.json();
|
|
113
|
-
expect(data.status).toBe("ready");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ============================================================================
|
|
118
|
-
// Initialization Tests
|
|
119
|
-
// ============================================================================
|
|
120
|
-
|
|
121
|
-
describe("agentmail_init", () => {
|
|
122
|
-
it("creates project and registers agent", async () => {
|
|
123
|
-
const ctx = createTestContext();
|
|
124
|
-
|
|
125
|
-
const { project, agent, state } = await initTestAgent(ctx);
|
|
126
|
-
|
|
127
|
-
expect(project.id).toBeGreaterThan(0);
|
|
128
|
-
expect(project.human_key).toBe(ctx.projectKey);
|
|
129
|
-
expect(agent.id).toBeGreaterThan(0);
|
|
130
|
-
expect(agent.name).toBeTruthy();
|
|
131
|
-
expect(state.projectKey).toBe(ctx.projectKey);
|
|
132
|
-
expect(state.agentName).toBe(agent.name);
|
|
133
|
-
|
|
134
|
-
// Cleanup
|
|
135
|
-
clearState(ctx.sessionID);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("generates unique agent name when not provided", async () => {
|
|
139
|
-
const ctx = createTestContext();
|
|
140
|
-
|
|
141
|
-
const { agent: agent1 } = await initTestAgent(ctx);
|
|
142
|
-
clearState(ctx.sessionID);
|
|
143
|
-
|
|
144
|
-
// Register another agent without name
|
|
145
|
-
const ctx2 = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
146
|
-
const { agent: agent2 } = await initTestAgent(ctx2);
|
|
147
|
-
|
|
148
|
-
// Both should have adjective+noun style names
|
|
149
|
-
expect(agent1.name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
|
|
150
|
-
expect(agent2.name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
|
|
151
|
-
|
|
152
|
-
// Cleanup
|
|
153
|
-
clearState(ctx.sessionID);
|
|
154
|
-
clearState(ctx2.sessionID);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("uses provided agent name when specified (valid adjective+noun)", async () => {
|
|
158
|
-
const ctx = createTestContext();
|
|
159
|
-
// Server has a specific word list - use known-valid combinations
|
|
160
|
-
// Valid: BlueLake, GreenDog, RedStone, BlueBear
|
|
161
|
-
const customName = "BlueLake";
|
|
162
|
-
|
|
163
|
-
const { agent } = await initTestAgent(ctx, customName);
|
|
164
|
-
|
|
165
|
-
expect(agent.name).toBe(customName);
|
|
166
|
-
|
|
167
|
-
// Cleanup
|
|
168
|
-
clearState(ctx.sessionID);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("re-registering same name updates existing agent (dedup by name)", async () => {
|
|
172
|
-
// Note: Real Agent Mail deduplicates by name within a project
|
|
173
|
-
// Re-registering with same name updates the existing agent (same ID)
|
|
174
|
-
const ctx = createTestContext();
|
|
175
|
-
// Use a valid name from the server's word list
|
|
176
|
-
const customName = "GreenDog";
|
|
177
|
-
|
|
178
|
-
const { agent: agent1 } = await initTestAgent(ctx, customName);
|
|
179
|
-
clearState(ctx.sessionID);
|
|
180
|
-
|
|
181
|
-
// Re-register with same name - updates existing agent
|
|
182
|
-
const ctx2 = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
183
|
-
const { agent: agent2 } = await initTestAgent(ctx2, customName);
|
|
184
|
-
|
|
185
|
-
// Same name, same ID (updated, not duplicated)
|
|
186
|
-
expect(agent1.name).toBe(agent2.name);
|
|
187
|
-
expect(agent1.id).toBe(agent2.id);
|
|
188
|
-
|
|
189
|
-
// Cleanup
|
|
190
|
-
clearState(ctx2.sessionID);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// ============================================================================
|
|
195
|
-
// State Management Tests
|
|
196
|
-
// ============================================================================
|
|
197
|
-
|
|
198
|
-
describe("state management", () => {
|
|
199
|
-
it("requireState throws when not initialized", () => {
|
|
200
|
-
const sessionID = "nonexistent-session";
|
|
201
|
-
|
|
202
|
-
expect(() => requireState(sessionID)).toThrow(
|
|
203
|
-
AgentMailNotInitializedError,
|
|
204
|
-
);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("requireState returns state when initialized", async () => {
|
|
208
|
-
const ctx = createTestContext();
|
|
209
|
-
await initTestAgent(ctx);
|
|
210
|
-
|
|
211
|
-
const state = requireState(ctx.sessionID);
|
|
212
|
-
expect(state.projectKey).toBe(ctx.projectKey);
|
|
213
|
-
|
|
214
|
-
// Cleanup
|
|
215
|
-
clearState(ctx.sessionID);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("clearState removes session state", async () => {
|
|
219
|
-
const ctx = createTestContext();
|
|
220
|
-
await initTestAgent(ctx);
|
|
221
|
-
|
|
222
|
-
expect(sessionStates.has(ctx.sessionID)).toBe(true);
|
|
223
|
-
clearState(ctx.sessionID);
|
|
224
|
-
expect(sessionStates.has(ctx.sessionID)).toBe(false);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// ============================================================================
|
|
229
|
-
// Messaging Tests
|
|
230
|
-
// ============================================================================
|
|
231
|
-
|
|
232
|
-
describe("agentmail_send", () => {
|
|
233
|
-
it("sends message to another agent", async () => {
|
|
234
|
-
const ctx = createTestContext();
|
|
235
|
-
const { state: senderState } = await initTestAgent(
|
|
236
|
-
ctx,
|
|
237
|
-
`Sender_${Date.now()}`,
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
// Create recipient agent
|
|
241
|
-
const recipientCtx = {
|
|
242
|
-
...createTestContext(),
|
|
243
|
-
projectKey: ctx.projectKey,
|
|
244
|
-
};
|
|
245
|
-
const { state: recipientState } = await initTestAgent(
|
|
246
|
-
recipientCtx,
|
|
247
|
-
`Recipient_${Date.now()}`,
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
// Send message
|
|
251
|
-
// Real Agent Mail returns { deliveries: [{ payload: { id, subject, ... } }], count }
|
|
252
|
-
const result = await mcpCall<{
|
|
253
|
-
deliveries: Array<{
|
|
254
|
-
payload: { id: number; subject: string; to: string[] };
|
|
255
|
-
}>;
|
|
256
|
-
count: number;
|
|
257
|
-
}>("send_message", {
|
|
258
|
-
project_key: senderState.projectKey,
|
|
259
|
-
sender_name: senderState.agentName,
|
|
260
|
-
to: [recipientState.agentName],
|
|
261
|
-
subject: "Test message",
|
|
262
|
-
body_md: "This is a test message body",
|
|
263
|
-
thread_id: "bd-test-123",
|
|
264
|
-
importance: "normal",
|
|
265
|
-
ack_required: false,
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
expect(result.count).toBe(1);
|
|
269
|
-
expect(result.deliveries[0].payload.id).toBeGreaterThan(0);
|
|
270
|
-
expect(result.deliveries[0].payload.subject).toBe("Test message");
|
|
271
|
-
expect(result.deliveries[0].payload.to).toContain(
|
|
272
|
-
recipientState.agentName,
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
// Cleanup
|
|
276
|
-
clearState(ctx.sessionID);
|
|
277
|
-
clearState(recipientCtx.sessionID);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("sends urgent message with ack_required", async () => {
|
|
281
|
-
const ctx = createTestContext();
|
|
282
|
-
const { state: senderState } = await initTestAgent(
|
|
283
|
-
ctx,
|
|
284
|
-
`UrgentSender_${Date.now()}`,
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const recipientCtx = {
|
|
288
|
-
...createTestContext(),
|
|
289
|
-
projectKey: ctx.projectKey,
|
|
290
|
-
};
|
|
291
|
-
const { state: recipientState } = await initTestAgent(
|
|
292
|
-
recipientCtx,
|
|
293
|
-
`UrgentRecipient_${Date.now()}`,
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
// Real Agent Mail returns { deliveries: [...], count }
|
|
297
|
-
const result = await mcpCall<{
|
|
298
|
-
deliveries: Array<{ payload: { id: number } }>;
|
|
299
|
-
count: number;
|
|
300
|
-
}>("send_message", {
|
|
301
|
-
project_key: senderState.projectKey,
|
|
302
|
-
sender_name: senderState.agentName,
|
|
303
|
-
to: [recipientState.agentName],
|
|
304
|
-
subject: "Urgent: Action required",
|
|
305
|
-
body_md: "Please acknowledge this message",
|
|
306
|
-
importance: "urgent",
|
|
307
|
-
ack_required: true,
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
expect(result.count).toBe(1);
|
|
311
|
-
expect(result.deliveries[0].payload.id).toBeGreaterThan(0);
|
|
312
|
-
|
|
313
|
-
// Cleanup
|
|
314
|
-
clearState(ctx.sessionID);
|
|
315
|
-
clearState(recipientCtx.sessionID);
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// ============================================================================
|
|
320
|
-
// Inbox Tests
|
|
321
|
-
// ============================================================================
|
|
322
|
-
|
|
323
|
-
describe("agentmail_inbox", () => {
|
|
324
|
-
it("fetches messages without bodies by default (context-safe)", async () => {
|
|
325
|
-
const ctx = createTestContext();
|
|
326
|
-
const { state: senderState } = await initTestAgent(
|
|
327
|
-
ctx,
|
|
328
|
-
`InboxSender_${Date.now()}`,
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
const recipientCtx = {
|
|
332
|
-
...createTestContext(),
|
|
333
|
-
projectKey: ctx.projectKey,
|
|
334
|
-
};
|
|
335
|
-
const { state: recipientState } = await initTestAgent(
|
|
336
|
-
recipientCtx,
|
|
337
|
-
`InboxRecipient_${Date.now()}`,
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
// Send a message
|
|
341
|
-
await mcpCall("send_message", {
|
|
342
|
-
project_key: senderState.projectKey,
|
|
343
|
-
sender_name: senderState.agentName,
|
|
344
|
-
to: [recipientState.agentName],
|
|
345
|
-
subject: "Inbox test message",
|
|
346
|
-
body_md: "This body should NOT be included by default",
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// Fetch inbox WITHOUT bodies
|
|
350
|
-
// Real Agent Mail returns { result: [...] } wrapper
|
|
351
|
-
const response = await mcpCall<{
|
|
352
|
-
result: Array<{
|
|
353
|
-
id: number;
|
|
354
|
-
subject: string;
|
|
355
|
-
from: string;
|
|
356
|
-
body_md?: string;
|
|
357
|
-
}>;
|
|
358
|
-
}>("fetch_inbox", {
|
|
359
|
-
project_key: recipientState.projectKey,
|
|
360
|
-
agent_name: recipientState.agentName,
|
|
361
|
-
limit: 5,
|
|
362
|
-
include_bodies: false, // MANDATORY context-safe default
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
const messages = response.result;
|
|
366
|
-
expect(messages.length).toBeGreaterThan(0);
|
|
367
|
-
const testMsg = messages.find((m) => m.subject === "Inbox test message");
|
|
368
|
-
expect(testMsg).toBeDefined();
|
|
369
|
-
expect(testMsg?.from).toBe(senderState.agentName);
|
|
370
|
-
// Body should NOT be included when include_bodies: false
|
|
371
|
-
expect(testMsg?.body_md).toBeUndefined();
|
|
372
|
-
|
|
373
|
-
// Cleanup
|
|
374
|
-
clearState(ctx.sessionID);
|
|
375
|
-
clearState(recipientCtx.sessionID);
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
it("enforces MAX_INBOX_LIMIT constraint", async () => {
|
|
379
|
-
const ctx = createTestContext();
|
|
380
|
-
const { state: senderState } = await initTestAgent(
|
|
381
|
-
ctx,
|
|
382
|
-
`LimitSender_${Date.now()}`,
|
|
383
|
-
);
|
|
384
|
-
|
|
385
|
-
const recipientCtx = {
|
|
386
|
-
...createTestContext(),
|
|
387
|
-
projectKey: ctx.projectKey,
|
|
388
|
-
};
|
|
389
|
-
const { state: recipientState } = await initTestAgent(
|
|
390
|
-
recipientCtx,
|
|
391
|
-
`LimitRecipient_${Date.now()}`,
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
// Send more messages than MAX_INBOX_LIMIT
|
|
395
|
-
const messageCount = MAX_INBOX_LIMIT + 3;
|
|
396
|
-
for (let i = 0; i < messageCount; i++) {
|
|
397
|
-
await mcpCall("send_message", {
|
|
398
|
-
project_key: senderState.projectKey,
|
|
399
|
-
sender_name: senderState.agentName,
|
|
400
|
-
to: [recipientState.agentName],
|
|
401
|
-
subject: `Limit test message ${i}`,
|
|
402
|
-
body_md: `Message body ${i}`,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Request more than MAX_INBOX_LIMIT
|
|
407
|
-
const response = await mcpCall<{ result: Array<{ id: number }> }>(
|
|
408
|
-
"fetch_inbox",
|
|
409
|
-
{
|
|
410
|
-
project_key: recipientState.projectKey,
|
|
411
|
-
agent_name: recipientState.agentName,
|
|
412
|
-
limit: messageCount, // Request more than allowed
|
|
413
|
-
include_bodies: false,
|
|
414
|
-
},
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
// Should still return the requested amount from server
|
|
418
|
-
// The constraint enforcement happens in the tool wrapper, not mcpCall
|
|
419
|
-
expect(response.result.length).toBeGreaterThanOrEqual(MAX_INBOX_LIMIT);
|
|
420
|
-
|
|
421
|
-
// Cleanup
|
|
422
|
-
clearState(ctx.sessionID);
|
|
423
|
-
clearState(recipientCtx.sessionID);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("filters urgent messages when urgent_only is true", async () => {
|
|
427
|
-
const ctx = createTestContext();
|
|
428
|
-
const { state: senderState } = await initTestAgent(
|
|
429
|
-
ctx,
|
|
430
|
-
`UrgentFilterSender_${Date.now()}`,
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
const recipientCtx = {
|
|
434
|
-
...createTestContext(),
|
|
435
|
-
projectKey: ctx.projectKey,
|
|
436
|
-
};
|
|
437
|
-
const { state: recipientState } = await initTestAgent(
|
|
438
|
-
recipientCtx,
|
|
439
|
-
`UrgentFilterRecipient_${Date.now()}`,
|
|
440
|
-
);
|
|
441
|
-
|
|
442
|
-
// Send normal and urgent messages
|
|
443
|
-
await mcpCall("send_message", {
|
|
444
|
-
project_key: senderState.projectKey,
|
|
445
|
-
sender_name: senderState.agentName,
|
|
446
|
-
to: [recipientState.agentName],
|
|
447
|
-
subject: "Normal message",
|
|
448
|
-
body_md: "Not urgent",
|
|
449
|
-
importance: "normal",
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
await mcpCall("send_message", {
|
|
453
|
-
project_key: senderState.projectKey,
|
|
454
|
-
sender_name: senderState.agentName,
|
|
455
|
-
to: [recipientState.agentName],
|
|
456
|
-
subject: "Urgent message",
|
|
457
|
-
body_md: "Very urgent!",
|
|
458
|
-
importance: "urgent",
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// Fetch only urgent messages
|
|
462
|
-
const response = await mcpCall<{
|
|
463
|
-
result: Array<{ subject: string; importance: string }>;
|
|
464
|
-
}>("fetch_inbox", {
|
|
465
|
-
project_key: recipientState.projectKey,
|
|
466
|
-
agent_name: recipientState.agentName,
|
|
467
|
-
limit: 10,
|
|
468
|
-
include_bodies: false,
|
|
469
|
-
urgent_only: true,
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
const messages = response.result;
|
|
473
|
-
// All returned messages should be urgent
|
|
474
|
-
for (const msg of messages) {
|
|
475
|
-
expect(msg.importance).toBe("urgent");
|
|
476
|
-
}
|
|
477
|
-
expect(messages.some((m) => m.subject === "Urgent message")).toBe(true);
|
|
478
|
-
|
|
479
|
-
// Cleanup
|
|
480
|
-
clearState(ctx.sessionID);
|
|
481
|
-
clearState(recipientCtx.sessionID);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
it("filters by since_ts timestamp", async () => {
|
|
485
|
-
const ctx = createTestContext();
|
|
486
|
-
const { state: senderState } = await initTestAgent(
|
|
487
|
-
ctx,
|
|
488
|
-
`TimeSender_${Date.now()}`,
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
const recipientCtx = {
|
|
492
|
-
...createTestContext(),
|
|
493
|
-
projectKey: ctx.projectKey,
|
|
494
|
-
};
|
|
495
|
-
const { state: recipientState } = await initTestAgent(
|
|
496
|
-
recipientCtx,
|
|
497
|
-
`TimeRecipient_${Date.now()}`,
|
|
498
|
-
);
|
|
499
|
-
|
|
500
|
-
// Send first message
|
|
501
|
-
await mcpCall("send_message", {
|
|
502
|
-
project_key: senderState.projectKey,
|
|
503
|
-
sender_name: senderState.agentName,
|
|
504
|
-
to: [recipientState.agentName],
|
|
505
|
-
subject: "Old message",
|
|
506
|
-
body_md: "Sent before timestamp",
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// Wait a moment and capture timestamp
|
|
510
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
511
|
-
const sinceTs = new Date().toISOString();
|
|
512
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
513
|
-
|
|
514
|
-
// Send second message after timestamp
|
|
515
|
-
await mcpCall("send_message", {
|
|
516
|
-
project_key: senderState.projectKey,
|
|
517
|
-
sender_name: senderState.agentName,
|
|
518
|
-
to: [recipientState.agentName],
|
|
519
|
-
subject: "New message",
|
|
520
|
-
body_md: "Sent after timestamp",
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
// Fetch only messages after timestamp
|
|
524
|
-
const response = await mcpCall<{
|
|
525
|
-
result: Array<{ subject: string }>;
|
|
526
|
-
}>("fetch_inbox", {
|
|
527
|
-
project_key: recipientState.projectKey,
|
|
528
|
-
agent_name: recipientState.agentName,
|
|
529
|
-
limit: 10,
|
|
530
|
-
include_bodies: false,
|
|
531
|
-
since_ts: sinceTs,
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
const messages = response.result;
|
|
535
|
-
expect(messages.some((m) => m.subject === "New message")).toBe(true);
|
|
536
|
-
expect(messages.some((m) => m.subject === "Old message")).toBe(false);
|
|
537
|
-
|
|
538
|
-
// Cleanup
|
|
539
|
-
clearState(ctx.sessionID);
|
|
540
|
-
clearState(recipientCtx.sessionID);
|
|
541
|
-
});
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
// ============================================================================
|
|
545
|
-
// Read Message Tests
|
|
546
|
-
// ============================================================================
|
|
547
|
-
|
|
548
|
-
describe("agentmail_read_message", () => {
|
|
549
|
-
it("marks message as read", async () => {
|
|
550
|
-
const ctx = createTestContext();
|
|
551
|
-
const { state: senderState } = await initTestAgent(
|
|
552
|
-
ctx,
|
|
553
|
-
`ReadSender_${Date.now()}`,
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
const recipientCtx = {
|
|
557
|
-
...createTestContext(),
|
|
558
|
-
projectKey: ctx.projectKey,
|
|
559
|
-
};
|
|
560
|
-
const { state: recipientState } = await initTestAgent(
|
|
561
|
-
recipientCtx,
|
|
562
|
-
`ReadRecipient_${Date.now()}`,
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
// Send a message
|
|
566
|
-
// Real Agent Mail returns { deliveries: [{ payload: { id, ... } }] }
|
|
567
|
-
const sentMsg = await mcpCall<{
|
|
568
|
-
deliveries: Array<{ payload: { id: number } }>;
|
|
569
|
-
}>("send_message", {
|
|
570
|
-
project_key: senderState.projectKey,
|
|
571
|
-
sender_name: senderState.agentName,
|
|
572
|
-
to: [recipientState.agentName],
|
|
573
|
-
subject: "Read test message",
|
|
574
|
-
body_md: "This message will be marked as read",
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
const messageId = sentMsg.deliveries[0].payload.id;
|
|
578
|
-
|
|
579
|
-
// Mark as read
|
|
580
|
-
// Real Agent Mail returns { message_id, read: bool, read_at: iso8601 | null }
|
|
581
|
-
const result = await mcpCall<{
|
|
582
|
-
message_id: number;
|
|
583
|
-
read: boolean;
|
|
584
|
-
read_at: string | null;
|
|
585
|
-
}>("mark_message_read", {
|
|
586
|
-
project_key: recipientState.projectKey,
|
|
587
|
-
agent_name: recipientState.agentName,
|
|
588
|
-
message_id: messageId,
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
expect(result.message_id).toBe(messageId);
|
|
592
|
-
expect(result.read).toBe(true);
|
|
593
|
-
expect(result.read_at).toBeTruthy();
|
|
594
|
-
|
|
595
|
-
// Cleanup
|
|
596
|
-
clearState(ctx.sessionID);
|
|
597
|
-
clearState(recipientCtx.sessionID);
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
// ============================================================================
|
|
602
|
-
// Thread Summary Tests
|
|
603
|
-
// ============================================================================
|
|
604
|
-
|
|
605
|
-
describe("agentmail_summarize_thread", () => {
|
|
606
|
-
// Skip: summarize_thread requires LLM which may not be available
|
|
607
|
-
it.skip("summarizes messages in a thread", async () => {
|
|
608
|
-
const ctx = createTestContext();
|
|
609
|
-
const { state: senderState } = await initTestAgent(
|
|
610
|
-
ctx,
|
|
611
|
-
`ThreadSender_${Date.now()}`,
|
|
612
|
-
);
|
|
613
|
-
|
|
614
|
-
const recipientCtx = {
|
|
615
|
-
...createTestContext(),
|
|
616
|
-
projectKey: ctx.projectKey,
|
|
617
|
-
};
|
|
618
|
-
const { state: recipientState } = await initTestAgent(
|
|
619
|
-
recipientCtx,
|
|
620
|
-
`ThreadRecipient_${Date.now()}`,
|
|
621
|
-
);
|
|
622
|
-
|
|
623
|
-
const threadId = `thread-${Date.now()}`;
|
|
624
|
-
|
|
625
|
-
// Send multiple messages in the same thread
|
|
626
|
-
await mcpCall("send_message", {
|
|
627
|
-
project_key: senderState.projectKey,
|
|
628
|
-
sender_name: senderState.agentName,
|
|
629
|
-
to: [recipientState.agentName],
|
|
630
|
-
subject: "Thread message 1",
|
|
631
|
-
body_md: "First message in thread",
|
|
632
|
-
thread_id: threadId,
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
await mcpCall("send_message", {
|
|
636
|
-
project_key: senderState.projectKey,
|
|
637
|
-
sender_name: senderState.agentName,
|
|
638
|
-
to: [recipientState.agentName],
|
|
639
|
-
subject: "Thread message 2",
|
|
640
|
-
body_md: "Second message in thread",
|
|
641
|
-
thread_id: threadId,
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
// Get thread summary
|
|
645
|
-
const summary = await mcpCall<{
|
|
646
|
-
thread_id: string;
|
|
647
|
-
summary: {
|
|
648
|
-
participants: string[];
|
|
649
|
-
key_points: string[];
|
|
650
|
-
action_items: string[];
|
|
651
|
-
total_messages: number;
|
|
652
|
-
};
|
|
653
|
-
}>("summarize_thread", {
|
|
654
|
-
project_key: senderState.projectKey,
|
|
655
|
-
thread_id: threadId,
|
|
656
|
-
include_examples: false,
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
expect(summary.thread_id).toBe(threadId);
|
|
660
|
-
expect(summary.summary.participants).toContain(senderState.agentName);
|
|
661
|
-
expect(summary.summary.total_messages).toBe(2);
|
|
662
|
-
expect(summary.summary.key_points.length).toBeGreaterThan(0);
|
|
663
|
-
|
|
664
|
-
// Cleanup
|
|
665
|
-
clearState(ctx.sessionID);
|
|
666
|
-
clearState(recipientCtx.sessionID);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
// Skip: summarize_thread requires LLM which may not be available
|
|
670
|
-
it.skip("includes example messages when requested", async () => {
|
|
671
|
-
const ctx = createTestContext();
|
|
672
|
-
const { state: senderState } = await initTestAgent(
|
|
673
|
-
ctx,
|
|
674
|
-
`ExampleSender_${Date.now()}`,
|
|
675
|
-
);
|
|
676
|
-
|
|
677
|
-
const recipientCtx = {
|
|
678
|
-
...createTestContext(),
|
|
679
|
-
projectKey: ctx.projectKey,
|
|
680
|
-
};
|
|
681
|
-
const { state: recipientState } = await initTestAgent(
|
|
682
|
-
recipientCtx,
|
|
683
|
-
`ExampleRecipient_${Date.now()}`,
|
|
684
|
-
);
|
|
685
|
-
|
|
686
|
-
const threadId = `example-thread-${Date.now()}`;
|
|
687
|
-
|
|
688
|
-
await mcpCall("send_message", {
|
|
689
|
-
project_key: senderState.projectKey,
|
|
690
|
-
sender_name: senderState.agentName,
|
|
691
|
-
to: [recipientState.agentName],
|
|
692
|
-
subject: "Example thread message",
|
|
693
|
-
body_md: "This should be in examples",
|
|
694
|
-
thread_id: threadId,
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// Get summary with examples
|
|
698
|
-
const summary = await mcpCall<{
|
|
699
|
-
thread_id: string;
|
|
700
|
-
examples?: Array<{
|
|
701
|
-
id: number;
|
|
702
|
-
subject: string;
|
|
703
|
-
from: string;
|
|
704
|
-
body_md?: string;
|
|
705
|
-
}>;
|
|
706
|
-
}>("summarize_thread", {
|
|
707
|
-
project_key: senderState.projectKey,
|
|
708
|
-
thread_id: threadId,
|
|
709
|
-
include_examples: true,
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
expect(summary.examples).toBeDefined();
|
|
713
|
-
expect(summary.examples!.length).toBeGreaterThan(0);
|
|
714
|
-
expect(summary.examples![0].subject).toBe("Example thread message");
|
|
715
|
-
expect(summary.examples![0].body_md).toBe("This should be in examples");
|
|
716
|
-
|
|
717
|
-
// Cleanup
|
|
718
|
-
clearState(ctx.sessionID);
|
|
719
|
-
clearState(recipientCtx.sessionID);
|
|
720
|
-
});
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
// ============================================================================
|
|
724
|
-
// File Reservation Tests
|
|
725
|
-
// ============================================================================
|
|
726
|
-
|
|
727
|
-
describe("agentmail_reserve", () => {
|
|
728
|
-
it("grants file reservations", async () => {
|
|
729
|
-
const ctx = createTestContext();
|
|
730
|
-
const { state } = await initTestAgent(ctx, `ReserveAgent_${Date.now()}`);
|
|
731
|
-
|
|
732
|
-
const result = await mcpCall<{
|
|
733
|
-
granted: Array<{
|
|
734
|
-
id: number;
|
|
735
|
-
path_pattern: string;
|
|
736
|
-
exclusive: boolean;
|
|
737
|
-
reason: string;
|
|
738
|
-
expires_ts: string;
|
|
739
|
-
}>;
|
|
740
|
-
conflicts: Array<{ path: string; holders: string[] }>;
|
|
741
|
-
}>("file_reservation_paths", {
|
|
742
|
-
project_key: state.projectKey,
|
|
743
|
-
agent_name: state.agentName,
|
|
744
|
-
paths: ["src/auth/**", "src/config.ts"],
|
|
745
|
-
ttl_seconds: 3600,
|
|
746
|
-
exclusive: true,
|
|
747
|
-
reason: "bd-test-123: Working on auth",
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
expect(result.granted.length).toBe(2);
|
|
751
|
-
expect(result.conflicts.length).toBe(0);
|
|
752
|
-
expect(result.granted[0].exclusive).toBe(true);
|
|
753
|
-
expect(result.granted[0].reason).toContain("bd-test-123");
|
|
754
|
-
|
|
755
|
-
// Cleanup
|
|
756
|
-
clearState(ctx.sessionID);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
it("detects conflicts with exclusive reservations", async () => {
|
|
760
|
-
const ctx = createTestContext();
|
|
761
|
-
const { state: agent1State } = await initTestAgent(
|
|
762
|
-
ctx,
|
|
763
|
-
`ConflictAgent1_${Date.now()}`,
|
|
764
|
-
);
|
|
765
|
-
|
|
766
|
-
const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
767
|
-
const { state: agent2State } = await initTestAgent(
|
|
768
|
-
agent2Ctx,
|
|
769
|
-
`ConflictAgent2_${Date.now()}`,
|
|
770
|
-
);
|
|
771
|
-
|
|
772
|
-
const conflictPath = `src/conflict-${Date.now()}.ts`;
|
|
773
|
-
|
|
774
|
-
// Agent 1 reserves the file
|
|
775
|
-
const result1 = await mcpCall<{
|
|
776
|
-
granted: Array<{ id: number }>;
|
|
777
|
-
conflicts: Array<{ path: string; holders: string[] }>;
|
|
778
|
-
}>("file_reservation_paths", {
|
|
779
|
-
project_key: agent1State.projectKey,
|
|
780
|
-
agent_name: agent1State.agentName,
|
|
781
|
-
paths: [conflictPath],
|
|
782
|
-
ttl_seconds: 3600,
|
|
783
|
-
exclusive: true,
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
expect(result1.granted.length).toBe(1);
|
|
787
|
-
expect(result1.conflicts.length).toBe(0);
|
|
788
|
-
|
|
789
|
-
// Agent 2 tries to reserve the same file
|
|
790
|
-
// Real Agent Mail GRANTS the reservation but ALSO reports conflicts
|
|
791
|
-
// This is the expected behavior - it's a warning, not a block
|
|
792
|
-
const result2 = await mcpCall<{
|
|
793
|
-
granted: Array<{ id: number }>;
|
|
794
|
-
conflicts: Array<{
|
|
795
|
-
path: string;
|
|
796
|
-
holders: Array<{ agent: string; path_pattern: string }>;
|
|
797
|
-
}>;
|
|
798
|
-
}>("file_reservation_paths", {
|
|
799
|
-
project_key: agent2State.projectKey,
|
|
800
|
-
agent_name: agent2State.agentName,
|
|
801
|
-
paths: [conflictPath],
|
|
802
|
-
ttl_seconds: 3600,
|
|
803
|
-
exclusive: true,
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
// Server grants the reservation but reports conflicts
|
|
807
|
-
expect(result2.granted.length).toBe(1);
|
|
808
|
-
expect(result2.conflicts.length).toBe(1);
|
|
809
|
-
expect(result2.conflicts[0].path).toBe(conflictPath);
|
|
810
|
-
// holders is an array of objects with agent field
|
|
811
|
-
expect(
|
|
812
|
-
result2.conflicts[0].holders.some(
|
|
813
|
-
(h) => h.agent === agent1State.agentName,
|
|
814
|
-
),
|
|
815
|
-
).toBe(true);
|
|
816
|
-
|
|
817
|
-
// Cleanup
|
|
818
|
-
clearState(ctx.sessionID);
|
|
819
|
-
clearState(agent2Ctx.sessionID);
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
it("stores reservation IDs in state", async () => {
|
|
823
|
-
const ctx = createTestContext();
|
|
824
|
-
const { state } = await initTestAgent(ctx, `StateAgent_${Date.now()}`);
|
|
825
|
-
|
|
826
|
-
const result = await mcpCall<{
|
|
827
|
-
granted: Array<{ id: number }>;
|
|
828
|
-
}>("file_reservation_paths", {
|
|
829
|
-
project_key: state.projectKey,
|
|
830
|
-
agent_name: state.agentName,
|
|
831
|
-
paths: ["src/state-test.ts"],
|
|
832
|
-
ttl_seconds: 3600,
|
|
833
|
-
exclusive: true,
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
// Manually track reservations like the tool does
|
|
837
|
-
const reservationIds = result.granted.map((r) => r.id);
|
|
838
|
-
state.reservations = [...state.reservations, ...reservationIds];
|
|
839
|
-
setState(ctx.sessionID, state);
|
|
840
|
-
|
|
841
|
-
// Verify state was updated
|
|
842
|
-
const updatedState = requireState(ctx.sessionID);
|
|
843
|
-
expect(updatedState.reservations.length).toBeGreaterThan(0);
|
|
844
|
-
expect(updatedState.reservations).toContain(result.granted[0].id);
|
|
845
|
-
|
|
846
|
-
// Cleanup
|
|
847
|
-
clearState(ctx.sessionID);
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
// ============================================================================
|
|
852
|
-
// Release Reservation Tests
|
|
853
|
-
// ============================================================================
|
|
854
|
-
|
|
855
|
-
describe("agentmail_release", () => {
|
|
856
|
-
it("releases all reservations for an agent", async () => {
|
|
857
|
-
const ctx = createTestContext();
|
|
858
|
-
const { state } = await initTestAgent(ctx, `ReleaseAgent_${Date.now()}`);
|
|
859
|
-
|
|
860
|
-
// Create reservations
|
|
861
|
-
await mcpCall("file_reservation_paths", {
|
|
862
|
-
project_key: state.projectKey,
|
|
863
|
-
agent_name: state.agentName,
|
|
864
|
-
paths: ["src/release-test-1.ts", "src/release-test-2.ts"],
|
|
865
|
-
ttl_seconds: 3600,
|
|
866
|
-
exclusive: true,
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
// Release all
|
|
870
|
-
const result = await mcpCall<{ released: number; released_at: string }>(
|
|
871
|
-
"release_file_reservations",
|
|
872
|
-
{
|
|
873
|
-
project_key: state.projectKey,
|
|
874
|
-
agent_name: state.agentName,
|
|
875
|
-
},
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
expect(result.released).toBe(2);
|
|
879
|
-
expect(result.released_at).toBeTruthy();
|
|
880
|
-
|
|
881
|
-
// Cleanup
|
|
882
|
-
clearState(ctx.sessionID);
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
it("releases specific paths only", async () => {
|
|
886
|
-
const ctx = createTestContext();
|
|
887
|
-
const { state } = await initTestAgent(
|
|
888
|
-
ctx,
|
|
889
|
-
`SpecificReleaseAgent_${Date.now()}`,
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
const path1 = `src/specific-release-1-${Date.now()}.ts`;
|
|
893
|
-
const path2 = `src/specific-release-2-${Date.now()}.ts`;
|
|
894
|
-
|
|
895
|
-
// Create reservations
|
|
896
|
-
await mcpCall("file_reservation_paths", {
|
|
897
|
-
project_key: state.projectKey,
|
|
898
|
-
agent_name: state.agentName,
|
|
899
|
-
paths: [path1, path2],
|
|
900
|
-
ttl_seconds: 3600,
|
|
901
|
-
exclusive: true,
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
// Release only one path
|
|
905
|
-
const result = await mcpCall<{ released: number }>(
|
|
906
|
-
"release_file_reservations",
|
|
907
|
-
{
|
|
908
|
-
project_key: state.projectKey,
|
|
909
|
-
agent_name: state.agentName,
|
|
910
|
-
paths: [path1],
|
|
911
|
-
},
|
|
912
|
-
);
|
|
913
|
-
|
|
914
|
-
expect(result.released).toBe(1);
|
|
915
|
-
|
|
916
|
-
// Verify second path can still cause conflicts
|
|
917
|
-
const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
918
|
-
const { state: agent2State } = await initTestAgent(
|
|
919
|
-
agent2Ctx,
|
|
920
|
-
`SpecificReleaseAgent2_${Date.now()}`,
|
|
921
|
-
);
|
|
922
|
-
|
|
923
|
-
const conflictResult = await mcpCall<{
|
|
924
|
-
conflicts: Array<{ path: string }>;
|
|
925
|
-
}>("file_reservation_paths", {
|
|
926
|
-
project_key: agent2State.projectKey,
|
|
927
|
-
agent_name: agent2State.agentName,
|
|
928
|
-
paths: [path2],
|
|
929
|
-
exclusive: true,
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
expect(conflictResult.conflicts.length).toBe(1);
|
|
933
|
-
|
|
934
|
-
// Cleanup
|
|
935
|
-
clearState(ctx.sessionID);
|
|
936
|
-
clearState(agent2Ctx.sessionID);
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
it("releases by reservation IDs", async () => {
|
|
940
|
-
const ctx = createTestContext();
|
|
941
|
-
const { state } = await initTestAgent(
|
|
942
|
-
ctx,
|
|
943
|
-
`IdReleaseAgent_${Date.now()}`,
|
|
944
|
-
);
|
|
945
|
-
|
|
946
|
-
// Create reservations
|
|
947
|
-
const reserveResult = await mcpCall<{
|
|
948
|
-
granted: Array<{ id: number }>;
|
|
949
|
-
}>("file_reservation_paths", {
|
|
950
|
-
project_key: state.projectKey,
|
|
951
|
-
agent_name: state.agentName,
|
|
952
|
-
paths: ["src/id-release-1.ts", "src/id-release-2.ts"],
|
|
953
|
-
ttl_seconds: 3600,
|
|
954
|
-
exclusive: true,
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
const firstId = reserveResult.granted[0].id;
|
|
958
|
-
|
|
959
|
-
// Release by ID
|
|
960
|
-
const result = await mcpCall<{ released: number }>(
|
|
961
|
-
"release_file_reservations",
|
|
962
|
-
{
|
|
963
|
-
project_key: state.projectKey,
|
|
964
|
-
agent_name: state.agentName,
|
|
965
|
-
file_reservation_ids: [firstId],
|
|
966
|
-
},
|
|
967
|
-
);
|
|
968
|
-
|
|
969
|
-
expect(result.released).toBe(1);
|
|
970
|
-
|
|
971
|
-
// Cleanup
|
|
972
|
-
clearState(ctx.sessionID);
|
|
973
|
-
});
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
// ============================================================================
|
|
977
|
-
// Acknowledge Message Tests
|
|
978
|
-
// ============================================================================
|
|
979
|
-
|
|
980
|
-
describe("agentmail_ack", () => {
|
|
981
|
-
it("acknowledges a message requiring acknowledgement", async () => {
|
|
982
|
-
const ctx = createTestContext();
|
|
983
|
-
const { state: senderState } = await initTestAgent(
|
|
984
|
-
ctx,
|
|
985
|
-
`AckSender_${Date.now()}`,
|
|
986
|
-
);
|
|
987
|
-
|
|
988
|
-
const recipientCtx = {
|
|
989
|
-
...createTestContext(),
|
|
990
|
-
projectKey: ctx.projectKey,
|
|
991
|
-
};
|
|
992
|
-
const { state: recipientState } = await initTestAgent(
|
|
993
|
-
recipientCtx,
|
|
994
|
-
`AckRecipient_${Date.now()}`,
|
|
995
|
-
);
|
|
996
|
-
|
|
997
|
-
// Send message requiring ack
|
|
998
|
-
// Real Agent Mail returns { deliveries: [{ payload: { id, ... } }] }
|
|
999
|
-
const sentMsg = await mcpCall<{
|
|
1000
|
-
deliveries: Array<{ payload: { id: number } }>;
|
|
1001
|
-
}>("send_message", {
|
|
1002
|
-
project_key: senderState.projectKey,
|
|
1003
|
-
sender_name: senderState.agentName,
|
|
1004
|
-
to: [recipientState.agentName],
|
|
1005
|
-
subject: "Please acknowledge",
|
|
1006
|
-
body_md: "This requires acknowledgement",
|
|
1007
|
-
ack_required: true,
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
const messageId = sentMsg.deliveries[0].payload.id;
|
|
1011
|
-
|
|
1012
|
-
// Acknowledge
|
|
1013
|
-
// Real Agent Mail returns { acknowledged: bool, acknowledged_at: iso8601 | null }
|
|
1014
|
-
const result = await mcpCall<{
|
|
1015
|
-
message_id: number;
|
|
1016
|
-
acknowledged: boolean;
|
|
1017
|
-
acknowledged_at: string | null;
|
|
1018
|
-
}>("acknowledge_message", {
|
|
1019
|
-
project_key: recipientState.projectKey,
|
|
1020
|
-
agent_name: recipientState.agentName,
|
|
1021
|
-
message_id: messageId,
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
expect(result.message_id).toBe(messageId);
|
|
1025
|
-
expect(result.acknowledged).toBe(true);
|
|
1026
|
-
expect(result.acknowledged_at).toBeTruthy();
|
|
1027
|
-
|
|
1028
|
-
// Cleanup
|
|
1029
|
-
clearState(ctx.sessionID);
|
|
1030
|
-
clearState(recipientCtx.sessionID);
|
|
1031
|
-
});
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
// ============================================================================
|
|
1035
|
-
// Search Tests
|
|
1036
|
-
// ============================================================================
|
|
1037
|
-
|
|
1038
|
-
describe("agentmail_search", () => {
|
|
1039
|
-
it("searches messages by keyword using FTS5", async () => {
|
|
1040
|
-
const ctx = createTestContext();
|
|
1041
|
-
const { state: senderState } = await initTestAgent(
|
|
1042
|
-
ctx,
|
|
1043
|
-
`SearchSender_${Date.now()}`,
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
const recipientCtx = {
|
|
1047
|
-
...createTestContext(),
|
|
1048
|
-
projectKey: ctx.projectKey,
|
|
1049
|
-
};
|
|
1050
|
-
const { state: recipientState } = await initTestAgent(
|
|
1051
|
-
recipientCtx,
|
|
1052
|
-
`SearchRecipient_${Date.now()}`,
|
|
1053
|
-
);
|
|
1054
|
-
|
|
1055
|
-
const uniqueKeyword = `unicorn${Date.now()}`;
|
|
1056
|
-
|
|
1057
|
-
// Send messages with searchable content
|
|
1058
|
-
await mcpCall("send_message", {
|
|
1059
|
-
project_key: senderState.projectKey,
|
|
1060
|
-
sender_name: senderState.agentName,
|
|
1061
|
-
to: [recipientState.agentName],
|
|
1062
|
-
subject: `Message about ${uniqueKeyword}`,
|
|
1063
|
-
body_md: "This message contains the keyword",
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
|
-
await mcpCall("send_message", {
|
|
1067
|
-
project_key: senderState.projectKey,
|
|
1068
|
-
sender_name: senderState.agentName,
|
|
1069
|
-
to: [recipientState.agentName],
|
|
1070
|
-
subject: "Unrelated message",
|
|
1071
|
-
body_md: "This message is about something else",
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
// Search
|
|
1075
|
-
// Real Agent Mail returns { result: [...] }
|
|
1076
|
-
const response = await mcpCall<{
|
|
1077
|
-
result: Array<{ id: number; subject: string }>;
|
|
1078
|
-
}>("search_messages", {
|
|
1079
|
-
project_key: senderState.projectKey,
|
|
1080
|
-
query: uniqueKeyword,
|
|
1081
|
-
limit: 10,
|
|
1082
|
-
});
|
|
1083
|
-
|
|
1084
|
-
const results = response.result;
|
|
1085
|
-
expect(results.length).toBeGreaterThan(0);
|
|
1086
|
-
expect(results.every((r) => r.subject.includes(uniqueKeyword))).toBe(
|
|
1087
|
-
true,
|
|
1088
|
-
);
|
|
1089
|
-
|
|
1090
|
-
// Cleanup
|
|
1091
|
-
clearState(ctx.sessionID);
|
|
1092
|
-
clearState(recipientCtx.sessionID);
|
|
1093
|
-
});
|
|
1094
|
-
|
|
1095
|
-
it("respects search limit", async () => {
|
|
1096
|
-
const ctx = createTestContext();
|
|
1097
|
-
const { state: senderState } = await initTestAgent(
|
|
1098
|
-
ctx,
|
|
1099
|
-
`LimitSearchSender_${Date.now()}`,
|
|
1100
|
-
);
|
|
1101
|
-
|
|
1102
|
-
const recipientCtx = {
|
|
1103
|
-
...createTestContext(),
|
|
1104
|
-
projectKey: ctx.projectKey,
|
|
1105
|
-
};
|
|
1106
|
-
const { state: recipientState } = await initTestAgent(
|
|
1107
|
-
recipientCtx,
|
|
1108
|
-
`LimitSearchRecipient_${Date.now()}`,
|
|
1109
|
-
);
|
|
1110
|
-
|
|
1111
|
-
const keyword = `searchlimit${Date.now()}`;
|
|
1112
|
-
|
|
1113
|
-
// Send multiple matching messages
|
|
1114
|
-
for (let i = 0; i < 5; i++) {
|
|
1115
|
-
await mcpCall("send_message", {
|
|
1116
|
-
project_key: senderState.projectKey,
|
|
1117
|
-
sender_name: senderState.agentName,
|
|
1118
|
-
to: [recipientState.agentName],
|
|
1119
|
-
subject: `${keyword} message ${i}`,
|
|
1120
|
-
body_md: `Content with ${keyword}`,
|
|
1121
|
-
});
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Search with limit
|
|
1125
|
-
// Real Agent Mail returns { result: [...] }
|
|
1126
|
-
const response = await mcpCall<{
|
|
1127
|
-
result: Array<{ id: number }>;
|
|
1128
|
-
}>("search_messages", {
|
|
1129
|
-
project_key: senderState.projectKey,
|
|
1130
|
-
query: keyword,
|
|
1131
|
-
limit: 2,
|
|
1132
|
-
});
|
|
1133
|
-
|
|
1134
|
-
expect(response.result.length).toBe(2);
|
|
1135
|
-
|
|
1136
|
-
// Cleanup
|
|
1137
|
-
clearState(ctx.sessionID);
|
|
1138
|
-
clearState(recipientCtx.sessionID);
|
|
1139
|
-
});
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
// ============================================================================
|
|
1143
|
-
// Error Handling Tests
|
|
1144
|
-
// ============================================================================
|
|
1145
|
-
|
|
1146
|
-
describe("error handling", () => {
|
|
1147
|
-
it("throws on unknown tool", async () => {
|
|
1148
|
-
// Real Agent Mail returns isError: true which mcpCall converts to throw
|
|
1149
|
-
await expect(mcpCall("nonexistent_tool", {})).rejects.toThrow(
|
|
1150
|
-
/Unknown tool/,
|
|
1151
|
-
);
|
|
1152
|
-
});
|
|
1153
|
-
|
|
1154
|
-
it("throws on missing required parameters", async () => {
|
|
1155
|
-
// Real Agent Mail returns validation error with isError: true
|
|
1156
|
-
await expect(mcpCall("ensure_project", {})).rejects.toThrow(
|
|
1157
|
-
/Missing required argument|validation error/,
|
|
1158
|
-
);
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
it("throws on invalid project reference", async () => {
|
|
1162
|
-
// Real Agent Mail auto-creates projects, so this actually succeeds
|
|
1163
|
-
// Instead test with a truly invalid operation
|
|
1164
|
-
await expect(
|
|
1165
|
-
mcpCall("register_agent", {
|
|
1166
|
-
// Missing required project_key
|
|
1167
|
-
program: "test",
|
|
1168
|
-
model: "test",
|
|
1169
|
-
}),
|
|
1170
|
-
).rejects.toThrow(/Missing required argument|validation error/);
|
|
1171
|
-
});
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
// ============================================================================
|
|
1175
|
-
// Multi-Agent Coordination Tests
|
|
1176
|
-
// ============================================================================
|
|
1177
|
-
|
|
1178
|
-
describe("multi-agent coordination", () => {
|
|
1179
|
-
it("enables communication between multiple agents", async () => {
|
|
1180
|
-
const ctx = createTestContext();
|
|
1181
|
-
|
|
1182
|
-
// Create 3 agents in the same project
|
|
1183
|
-
const agent1Ctx = ctx;
|
|
1184
|
-
const { state: agent1 } = await initTestAgent(
|
|
1185
|
-
agent1Ctx,
|
|
1186
|
-
`Coordinator_${Date.now()}`,
|
|
1187
|
-
);
|
|
1188
|
-
|
|
1189
|
-
const agent2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
1190
|
-
const { state: agent2 } = await initTestAgent(
|
|
1191
|
-
agent2Ctx,
|
|
1192
|
-
`Worker1_${Date.now()}`,
|
|
1193
|
-
);
|
|
1194
|
-
|
|
1195
|
-
const agent3Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
1196
|
-
const { state: agent3 } = await initTestAgent(
|
|
1197
|
-
agent3Ctx,
|
|
1198
|
-
`Worker2_${Date.now()}`,
|
|
1199
|
-
);
|
|
1200
|
-
|
|
1201
|
-
// Coordinator broadcasts to workers
|
|
1202
|
-
await mcpCall("send_message", {
|
|
1203
|
-
project_key: agent1.projectKey,
|
|
1204
|
-
sender_name: agent1.agentName,
|
|
1205
|
-
to: [agent2.agentName, agent3.agentName],
|
|
1206
|
-
subject: "Task assignment",
|
|
1207
|
-
body_md: "Please complete your subtasks",
|
|
1208
|
-
thread_id: "bd-epic-123",
|
|
1209
|
-
importance: "high",
|
|
1210
|
-
});
|
|
1211
|
-
|
|
1212
|
-
// Verify both workers received the message
|
|
1213
|
-
const worker1Response = await mcpCall<{
|
|
1214
|
-
result: Array<{ subject: string }>;
|
|
1215
|
-
}>("fetch_inbox", {
|
|
1216
|
-
project_key: agent2.projectKey,
|
|
1217
|
-
agent_name: agent2.agentName,
|
|
1218
|
-
limit: 5,
|
|
1219
|
-
include_bodies: false,
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
const worker2Response = await mcpCall<{
|
|
1223
|
-
result: Array<{ subject: string }>;
|
|
1224
|
-
}>("fetch_inbox", {
|
|
1225
|
-
project_key: agent3.projectKey,
|
|
1226
|
-
agent_name: agent3.agentName,
|
|
1227
|
-
limit: 5,
|
|
1228
|
-
include_bodies: false,
|
|
1229
|
-
});
|
|
1230
|
-
|
|
1231
|
-
expect(
|
|
1232
|
-
worker1Response.result.some((m) => m.subject === "Task assignment"),
|
|
1233
|
-
).toBe(true);
|
|
1234
|
-
expect(
|
|
1235
|
-
worker2Response.result.some((m) => m.subject === "Task assignment"),
|
|
1236
|
-
).toBe(true);
|
|
1237
|
-
|
|
1238
|
-
// Cleanup
|
|
1239
|
-
clearState(agent1Ctx.sessionID);
|
|
1240
|
-
clearState(agent2Ctx.sessionID);
|
|
1241
|
-
clearState(agent3Ctx.sessionID);
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
it("prevents file conflicts in swarm scenarios", async () => {
|
|
1245
|
-
const ctx = createTestContext();
|
|
1246
|
-
|
|
1247
|
-
// Coordinator assigns different files to workers
|
|
1248
|
-
const coordCtx = ctx;
|
|
1249
|
-
await initTestAgent(coordCtx, `SwarmCoord_${Date.now()}`);
|
|
1250
|
-
|
|
1251
|
-
const worker1Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
1252
|
-
const { state: worker1 } = await initTestAgent(
|
|
1253
|
-
worker1Ctx,
|
|
1254
|
-
`SwarmWorker1_${Date.now()}`,
|
|
1255
|
-
);
|
|
1256
|
-
|
|
1257
|
-
const worker2Ctx = { ...createTestContext(), projectKey: ctx.projectKey };
|
|
1258
|
-
const { state: worker2 } = await initTestAgent(
|
|
1259
|
-
worker2Ctx,
|
|
1260
|
-
`SwarmWorker2_${Date.now()}`,
|
|
1261
|
-
);
|
|
1262
|
-
|
|
1263
|
-
const path1 = `src/swarm/file1-${Date.now()}.ts`;
|
|
1264
|
-
const path2 = `src/swarm/file2-${Date.now()}.ts`;
|
|
1265
|
-
|
|
1266
|
-
// Worker 1 reserves file 1
|
|
1267
|
-
const res1 = await mcpCall<{
|
|
1268
|
-
granted: Array<{ id: number }>;
|
|
1269
|
-
conflicts: unknown[];
|
|
1270
|
-
}>("file_reservation_paths", {
|
|
1271
|
-
project_key: worker1.projectKey,
|
|
1272
|
-
agent_name: worker1.agentName,
|
|
1273
|
-
paths: [path1],
|
|
1274
|
-
exclusive: true,
|
|
1275
|
-
reason: "bd-subtask-1",
|
|
1276
|
-
});
|
|
1277
|
-
|
|
1278
|
-
// Worker 2 reserves file 2
|
|
1279
|
-
const res2 = await mcpCall<{
|
|
1280
|
-
granted: Array<{ id: number }>;
|
|
1281
|
-
conflicts: unknown[];
|
|
1282
|
-
}>("file_reservation_paths", {
|
|
1283
|
-
project_key: worker2.projectKey,
|
|
1284
|
-
agent_name: worker2.agentName,
|
|
1285
|
-
paths: [path2],
|
|
1286
|
-
exclusive: true,
|
|
1287
|
-
reason: "bd-subtask-2",
|
|
1288
|
-
});
|
|
1289
|
-
|
|
1290
|
-
// Both should succeed (no conflicts)
|
|
1291
|
-
expect(res1.granted.length).toBe(1);
|
|
1292
|
-
expect(res1.conflicts.length).toBe(0);
|
|
1293
|
-
expect(res2.granted.length).toBe(1);
|
|
1294
|
-
expect(res2.conflicts.length).toBe(0);
|
|
1295
|
-
|
|
1296
|
-
// Worker 1 tries to reserve file 2 (should conflict)
|
|
1297
|
-
// Real Agent Mail returns holders as array of objects with agent field
|
|
1298
|
-
const conflict = await mcpCall<{
|
|
1299
|
-
conflicts: Array<{
|
|
1300
|
-
path: string;
|
|
1301
|
-
holders: Array<{ agent: string; path_pattern: string }>;
|
|
1302
|
-
}>;
|
|
1303
|
-
}>("file_reservation_paths", {
|
|
1304
|
-
project_key: worker1.projectKey,
|
|
1305
|
-
agent_name: worker1.agentName,
|
|
1306
|
-
paths: [path2],
|
|
1307
|
-
exclusive: true,
|
|
1308
|
-
});
|
|
1309
|
-
|
|
1310
|
-
expect(conflict.conflicts.length).toBe(1);
|
|
1311
|
-
// holders is an array of objects with agent field
|
|
1312
|
-
expect(
|
|
1313
|
-
conflict.conflicts[0].holders.some(
|
|
1314
|
-
(h) => h.agent === worker2.agentName,
|
|
1315
|
-
),
|
|
1316
|
-
).toBe(true);
|
|
1317
|
-
|
|
1318
|
-
// Cleanup
|
|
1319
|
-
clearState(coordCtx.sessionID);
|
|
1320
|
-
clearState(worker1Ctx.sessionID);
|
|
1321
|
-
clearState(worker2Ctx.sessionID);
|
|
1322
|
-
});
|
|
1323
|
-
});
|
|
1324
|
-
|
|
1325
|
-
// ============================================================================
|
|
1326
|
-
// Self-Healing Tests (mcpCallWithAutoInit)
|
|
1327
|
-
// ============================================================================
|
|
1328
|
-
|
|
1329
|
-
describe("self-healing (mcpCallWithAutoInit)", () => {
|
|
1330
|
-
it("detects project not found errors correctly", () => {
|
|
1331
|
-
const projectError = new Error("Project 'migrate-egghead' not found.");
|
|
1332
|
-
const agentError = new Error("Agent 'BlueLake' not found in project");
|
|
1333
|
-
const otherError = new Error("Network timeout");
|
|
1334
|
-
|
|
1335
|
-
expect(isProjectNotFoundError(projectError)).toBe(true);
|
|
1336
|
-
expect(isProjectNotFoundError(agentError)).toBe(false);
|
|
1337
|
-
expect(isProjectNotFoundError(otherError)).toBe(false);
|
|
1338
|
-
|
|
1339
|
-
expect(isAgentNotFoundError(agentError)).toBe(true);
|
|
1340
|
-
expect(isAgentNotFoundError(projectError)).toBe(false);
|
|
1341
|
-
expect(isAgentNotFoundError(otherError)).toBe(false);
|
|
1342
|
-
});
|
|
1343
|
-
|
|
1344
|
-
it("auto-registers project on 'not found' error", async () => {
|
|
1345
|
-
const ctx = createTestContext();
|
|
1346
|
-
|
|
1347
|
-
// First, ensure project exists and register an agent
|
|
1348
|
-
const { state } = await initTestAgent(ctx, `AutoInit_${Date.now()}`);
|
|
1349
|
-
|
|
1350
|
-
// Now use mcpCallWithAutoInit - it should work normally
|
|
1351
|
-
// (no error to recover from, but verifies the wrapper works)
|
|
1352
|
-
await mcpCallWithAutoInit("send_message", {
|
|
1353
|
-
project_key: state.projectKey,
|
|
1354
|
-
agent_name: state.agentName,
|
|
1355
|
-
sender_name: state.agentName,
|
|
1356
|
-
to: [],
|
|
1357
|
-
subject: "Test auto-init wrapper",
|
|
1358
|
-
body_md: "This should work normally",
|
|
1359
|
-
thread_id: "test-thread",
|
|
1360
|
-
importance: "normal",
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
// Verify message was sent by checking inbox
|
|
1364
|
-
const inbox = await mcpCall<Array<{ subject: string }>>("fetch_inbox", {
|
|
1365
|
-
project_key: state.projectKey,
|
|
1366
|
-
agent_name: state.agentName,
|
|
1367
|
-
limit: 5,
|
|
1368
|
-
include_bodies: false,
|
|
1369
|
-
});
|
|
1370
|
-
|
|
1371
|
-
// The message should be in the inbox (sent to empty 'to' = broadcast)
|
|
1372
|
-
// Note: depending on Agent Mail behavior, broadcast might not show in sender's inbox
|
|
1373
|
-
// This test mainly verifies the wrapper doesn't break normal operation
|
|
1374
|
-
|
|
1375
|
-
// Cleanup
|
|
1376
|
-
clearState(ctx.sessionID);
|
|
1377
|
-
});
|
|
1378
|
-
|
|
1379
|
-
it("recovers from simulated project not found by re-registering", async () => {
|
|
1380
|
-
const ctx = createTestContext();
|
|
1381
|
-
|
|
1382
|
-
// Create a fresh project key that doesn't exist yet
|
|
1383
|
-
const freshProjectKey = `/test/fresh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1384
|
-
const agentName = `Recovery_${Date.now()}`;
|
|
1385
|
-
|
|
1386
|
-
// First ensure the project exists (simulating initial setup)
|
|
1387
|
-
await mcpCall("ensure_project", { human_key: freshProjectKey });
|
|
1388
|
-
await mcpCall("register_agent", {
|
|
1389
|
-
project_key: freshProjectKey,
|
|
1390
|
-
program: "opencode-test",
|
|
1391
|
-
model: "test-model",
|
|
1392
|
-
name: agentName,
|
|
1393
|
-
task_description: "Recovery test agent",
|
|
1394
|
-
});
|
|
1395
|
-
|
|
1396
|
-
// Now use mcpCallWithAutoInit for an operation
|
|
1397
|
-
// This should work, and if the project somehow got lost, it would re-register
|
|
1398
|
-
await mcpCallWithAutoInit("send_message", {
|
|
1399
|
-
project_key: freshProjectKey,
|
|
1400
|
-
agent_name: agentName,
|
|
1401
|
-
sender_name: agentName,
|
|
1402
|
-
to: [],
|
|
1403
|
-
subject: "Recovery test",
|
|
1404
|
-
body_md: "Testing self-healing",
|
|
1405
|
-
thread_id: "recovery-test",
|
|
1406
|
-
importance: "normal",
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
// If we got here without error, the wrapper is working
|
|
1410
|
-
// (In a real scenario where the server restarted, it would have re-registered)
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
it("passes through non-recoverable errors", async () => {
|
|
1414
|
-
const ctx = createTestContext();
|
|
1415
|
-
const { state } = await initTestAgent(ctx, `ErrorPass_${Date.now()}`);
|
|
1416
|
-
|
|
1417
|
-
// Try to call a non-existent tool - should throw, not retry forever
|
|
1418
|
-
await expect(
|
|
1419
|
-
mcpCallWithAutoInit("nonexistent_tool_xyz", {
|
|
1420
|
-
project_key: state.projectKey,
|
|
1421
|
-
agent_name: state.agentName,
|
|
1422
|
-
}),
|
|
1423
|
-
).rejects.toThrow(/Unknown tool/);
|
|
1424
|
-
|
|
1425
|
-
// Cleanup
|
|
1426
|
-
clearState(ctx.sessionID);
|
|
1427
|
-
});
|
|
1428
|
-
});
|
|
1429
|
-
});
|