@gethmy/mcp 2.5.0 → 2.5.2

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.
@@ -1,912 +0,0 @@
1
- /**
2
- * Unit tests for the Auto-Session tracking system.
3
- *
4
- * Run with: bun test packages/mcp-server/src/__tests__/auto-session.test.ts
5
- */
6
-
7
- import { afterEach, describe, expect, mock, test } from "bun:test";
8
- import {
9
- AUTO_START_TRIGGERS,
10
- checkInactivity,
11
- destroyAutoSession,
12
- getActiveSessions,
13
- INACTIVITY_TIMEOUT_MS,
14
- initAutoSession,
15
- markExplicit,
16
- shutdownAllSessions,
17
- trackActivity,
18
- untrack,
19
- } from "../auto-session.js";
20
-
21
- function makeMockClient() {
22
- return {
23
- startAgentSession: mock(async () => ({ session: { id: "sess-1" } })),
24
- endAgentSession: mock(async () => ({ session: { id: "sess-1" } })),
25
- getCard: mock(async () => ({
26
- card: { id: "card-1", title: "Test", labels: [], subtasks: [] },
27
- })),
28
- getAgentSession: mock(async () => ({ session: null })),
29
- };
30
- }
31
-
32
- const CARD_A = "aaaaaaaa-1111-1111-1111-111111111111";
33
- const CARD_B = "bbbbbbbb-2222-2222-2222-222222222222";
34
- const CARD_C = "cccccccc-3333-3333-3333-333333333333";
35
-
36
- afterEach(() => {
37
- destroyAutoSession();
38
- });
39
-
40
- // ─── Basic auto-start ──────────────────────────────────────────────
41
-
42
- describe("auto-start", () => {
43
- test("triggers session on autoStart=true with cardId", async () => {
44
- const client = makeMockClient();
45
- initAutoSession(
46
- mock(async () => {}),
47
- () => client as any,
48
- );
49
-
50
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
51
-
52
- expect(client.startAgentSession).toHaveBeenCalledTimes(1);
53
- expect(client.startAgentSession).toHaveBeenCalledWith(CARD_A, {
54
- agentIdentifier: "unknown",
55
- agentName: "Unknown Agent",
56
- status: "working",
57
- });
58
- expect(getActiveSessions().size).toBe(1);
59
- const session = getActiveSessions().get(CARD_A);
60
- expect(session).toBeDefined();
61
- expect(session!.isExplicit).toBe(false);
62
- expect(session!.agentIdentifier).toBe("unknown");
63
- expect(session!.agentName).toBe("Unknown Agent");
64
- });
65
-
66
- test("does NOT trigger on autoStart=false", async () => {
67
- const client = makeMockClient();
68
- initAutoSession(
69
- mock(async () => {}),
70
- () => client as any,
71
- );
72
-
73
- await trackActivity(CARD_A, { autoStart: false, client: client as any });
74
-
75
- expect(client.startAgentSession).not.toHaveBeenCalled();
76
- expect(getActiveSessions().size).toBe(0);
77
- });
78
-
79
- test("does NOT trigger when options is undefined", async () => {
80
- const client = makeMockClient();
81
- initAutoSession(
82
- mock(async () => {}),
83
- () => client as any,
84
- );
85
-
86
- await trackActivity(CARD_A);
87
-
88
- expect(client.startAgentSession).not.toHaveBeenCalled();
89
- expect(getActiveSessions().size).toBe(0);
90
- });
91
-
92
- test("does NOT trigger when no client is available", async () => {
93
- // initAutoSession not called — no clientGetter
94
- await trackActivity(CARD_A, { autoStart: true });
95
- expect(getActiveSessions().size).toBe(0);
96
- });
97
-
98
- test("still tracks locally when startAgentSession API throws", async () => {
99
- const client = makeMockClient();
100
- client.startAgentSession = mock(async () => {
101
- throw new Error("Session already exists");
102
- });
103
- initAutoSession(
104
- mock(async () => {}),
105
- () => client as any,
106
- );
107
-
108
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
109
-
110
- // Should still be tracked despite API error
111
- expect(getActiveSessions().size).toBe(1);
112
- expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("unknown");
113
- });
114
-
115
- test("uses clientGetter when no client in options", async () => {
116
- const client = makeMockClient();
117
- initAutoSession(
118
- mock(async () => {}),
119
- () => client as any,
120
- );
121
-
122
- await trackActivity(CARD_A, { autoStart: true });
123
-
124
- expect(client.startAgentSession).toHaveBeenCalledTimes(1);
125
- expect(getActiveSessions().size).toBe(1);
126
- });
127
- });
128
-
129
- // ─── Activity tracking ─────────────────────────────────────────────
130
-
131
- describe("activity tracking", () => {
132
- test("repeated activity updates lastActivityAt without re-starting", async () => {
133
- const client = makeMockClient();
134
- initAutoSession(
135
- mock(async () => {}),
136
- () => client as any,
137
- );
138
-
139
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
140
- const firstActivity = getActiveSessions().get(CARD_A)!.lastActivityAt;
141
-
142
- await new Promise((r) => setTimeout(r, 10));
143
-
144
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
145
-
146
- expect(client.startAgentSession).toHaveBeenCalledTimes(1);
147
- expect(getActiveSessions().get(CARD_A)!.lastActivityAt).toBeGreaterThan(
148
- firstActivity,
149
- );
150
- });
151
-
152
- test("activity on existing session works even without autoStart flag", async () => {
153
- const client = makeMockClient();
154
- initAutoSession(
155
- mock(async () => {}),
156
- () => client as any,
157
- );
158
-
159
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
160
- const firstActivity = getActiveSessions().get(CARD_A)!.lastActivityAt;
161
-
162
- await new Promise((r) => setTimeout(r, 10));
163
-
164
- // Second call with autoStart=false — should still update timestamp
165
- await trackActivity(CARD_A, { autoStart: false });
166
-
167
- expect(getActiveSessions().get(CARD_A)!.lastActivityAt).toBeGreaterThan(
168
- firstActivity,
169
- );
170
- });
171
-
172
- test("activity on existing explicit session updates timestamp", async () => {
173
- const client = makeMockClient();
174
- initAutoSession(
175
- mock(async () => {}),
176
- () => client as any,
177
- );
178
-
179
- markExplicit(CARD_A);
180
- const firstActivity = getActiveSessions().get(CARD_A)!.lastActivityAt;
181
-
182
- await new Promise((r) => setTimeout(r, 10));
183
-
184
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
185
-
186
- expect(getActiveSessions().get(CARD_A)!.lastActivityAt).toBeGreaterThan(
187
- firstActivity,
188
- );
189
- // Should NOT have called startAgentSession (session already exists)
190
- expect(client.startAgentSession).not.toHaveBeenCalled();
191
- });
192
- });
193
-
194
- // ─── Card switching ─────────────────────────────────────────────────
195
-
196
- describe("card switching", () => {
197
- test("switching cards auto-ends previous auto-session", async () => {
198
- const client = makeMockClient();
199
- const endCb = mock(async () => {});
200
- initAutoSession(endCb, () => client as any);
201
-
202
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
203
- expect(getActiveSessions().size).toBe(1);
204
-
205
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
206
-
207
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
208
- status: "completed",
209
- });
210
- expect(endCb).toHaveBeenCalledWith(client, CARD_A, "completed");
211
- expect(getActiveSessions().has(CARD_A)).toBe(false);
212
- expect(getActiveSessions().has(CARD_B)).toBe(true);
213
- });
214
-
215
- test("explicit sessions are NOT auto-ended by card switching", async () => {
216
- const client = makeMockClient();
217
- const endCb = mock(async () => {});
218
- initAutoSession(endCb, () => client as any);
219
-
220
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
221
- markExplicit(CARD_A);
222
-
223
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
224
-
225
- // Card A should still be tracked (explicit)
226
- expect(getActiveSessions().has(CARD_A)).toBe(true);
227
- expect(getActiveSessions().has(CARD_B)).toBe(true);
228
- expect(client.endAgentSession).not.toHaveBeenCalled();
229
- expect(endCb).not.toHaveBeenCalled();
230
- });
231
-
232
- test("switching from card A to B to C ends A then B correctly", async () => {
233
- const client = makeMockClient();
234
- const endCb = mock(async () => {});
235
- initAutoSession(endCb, () => client as any);
236
-
237
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
238
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
239
-
240
- // A should be ended, B should be active
241
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
242
- status: "completed",
243
- });
244
- expect(getActiveSessions().has(CARD_A)).toBe(false);
245
- expect(getActiveSessions().has(CARD_B)).toBe(true);
246
-
247
- client.endAgentSession.mockClear();
248
- endCb.mockClear();
249
-
250
- await trackActivity(CARD_C, { autoStart: true, client: client as any });
251
-
252
- // B should be ended, C should be active
253
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_B, {
254
- status: "completed",
255
- });
256
- expect(getActiveSessions().has(CARD_B)).toBe(false);
257
- expect(getActiveSessions().has(CARD_C)).toBe(true);
258
- });
259
-
260
- test("card switch with endAgentSession API error still cleans up tracking", async () => {
261
- const client = makeMockClient();
262
- client.endAgentSession = mock(async () => {
263
- throw new Error("API unavailable");
264
- });
265
- const endCb = mock(async () => {});
266
- initAutoSession(endCb, () => client as any);
267
-
268
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
269
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
270
-
271
- // Card A should still be removed from tracking despite API error
272
- expect(getActiveSessions().has(CARD_A)).toBe(false);
273
- expect(getActiveSessions().has(CARD_B)).toBe(true);
274
- // Callback should still run even though endAgentSession threw
275
- expect(endCb).toHaveBeenCalledWith(client, CARD_A, "completed");
276
- });
277
-
278
- test("card switch with endCallback error still cleans up tracking", async () => {
279
- const client = makeMockClient();
280
- const endCb = mock(async () => {
281
- throw new Error("Pipeline failed");
282
- });
283
- initAutoSession(endCb, () => client as any);
284
-
285
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
286
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
287
-
288
- // Card A should be cleaned up despite callback error
289
- expect(getActiveSessions().has(CARD_A)).toBe(false);
290
- expect(getActiveSessions().has(CARD_B)).toBe(true);
291
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
292
- status: "completed",
293
- });
294
- });
295
-
296
- test("card switch with explicit session on A only ends non-explicit sessions", async () => {
297
- const client = makeMockClient();
298
- const endCb = mock(async () => {});
299
- initAutoSession(endCb, () => client as any);
300
-
301
- // A is explicit, B is auto
302
- markExplicit(CARD_A);
303
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
304
-
305
- // Now switch to C — only B should be ended, A stays
306
- await trackActivity(CARD_C, { autoStart: true, client: client as any });
307
-
308
- expect(getActiveSessions().has(CARD_A)).toBe(true); // explicit, untouched
309
- expect(getActiveSessions().has(CARD_B)).toBe(false); // auto-ended
310
- expect(getActiveSessions().has(CARD_C)).toBe(true); // new auto
311
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_B, {
312
- status: "completed",
313
- });
314
- });
315
- });
316
-
317
- // ─── Inactivity timeout ─────────────────────────────────────────────
318
-
319
- describe("inactivity timeout", () => {
320
- test("checkInactivity ends sessions past the timeout", async () => {
321
- const client = makeMockClient();
322
- const endCb = mock(async () => {});
323
- initAutoSession(endCb, () => client as any);
324
-
325
- // Manually add a session with old lastActivityAt
326
- getActiveSessions().set(CARD_A, {
327
- cardId: CARD_A,
328
- startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
329
- lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 1000,
330
- isExplicit: false,
331
- agentIdentifier: "unknown",
332
- agentName: "Unknown Agent",
333
- });
334
-
335
- checkInactivity();
336
-
337
- // autoEndSession is fire-and-forget in checkInactivity, wait for it
338
- await new Promise((r) => setTimeout(r, 50));
339
-
340
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
341
- status: "completed",
342
- });
343
- expect(getActiveSessions().has(CARD_A)).toBe(false);
344
- });
345
-
346
- test("checkInactivity does NOT end sessions within timeout", () => {
347
- const client = makeMockClient();
348
- initAutoSession(
349
- mock(async () => {}),
350
- () => client as any,
351
- );
352
-
353
- getActiveSessions().set(CARD_A, {
354
- cardId: CARD_A,
355
- startedAt: Date.now(),
356
- lastActivityAt: Date.now(), // just now — well within timeout
357
- isExplicit: false,
358
- agentIdentifier: "unknown",
359
- agentName: "Unknown Agent",
360
- });
361
-
362
- checkInactivity();
363
-
364
- expect(client.endAgentSession).not.toHaveBeenCalled();
365
- expect(getActiveSessions().has(CARD_A)).toBe(true);
366
- });
367
-
368
- test("checkInactivity skips explicit sessions even if timed out", () => {
369
- const client = makeMockClient();
370
- initAutoSession(
371
- mock(async () => {}),
372
- () => client as any,
373
- );
374
-
375
- getActiveSessions().set(CARD_A, {
376
- cardId: CARD_A,
377
- startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 60000,
378
- lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 60000,
379
- isExplicit: true,
380
- agentIdentifier: "explicit",
381
- agentName: "Explicit Agent",
382
- });
383
-
384
- checkInactivity();
385
-
386
- expect(client.endAgentSession).not.toHaveBeenCalled();
387
- expect(getActiveSessions().has(CARD_A)).toBe(true);
388
- });
389
-
390
- test("checkInactivity handles mix of timed-out and active sessions", async () => {
391
- const client = makeMockClient();
392
- const endCb = mock(async () => {});
393
- initAutoSession(endCb, () => client as any);
394
-
395
- // CARD_A — timed out
396
- getActiveSessions().set(CARD_A, {
397
- cardId: CARD_A,
398
- startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
399
- lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
400
- isExplicit: false,
401
- agentIdentifier: "unknown",
402
- agentName: "Unknown Agent",
403
- });
404
-
405
- // CARD_B — still active
406
- getActiveSessions().set(CARD_B, {
407
- cardId: CARD_B,
408
- startedAt: Date.now() - 1000,
409
- lastActivityAt: Date.now() - 1000,
410
- isExplicit: false,
411
- agentIdentifier: "unknown",
412
- agentName: "Unknown Agent",
413
- });
414
-
415
- // CARD_C — timed out but explicit
416
- getActiveSessions().set(CARD_C, {
417
- cardId: CARD_C,
418
- startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
419
- lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
420
- isExplicit: true,
421
- agentIdentifier: "explicit",
422
- agentName: "Explicit Agent",
423
- });
424
-
425
- checkInactivity();
426
- await new Promise((r) => setTimeout(r, 50));
427
-
428
- // Only CARD_A should be ended
429
- expect(getActiveSessions().has(CARD_A)).toBe(false);
430
- expect(getActiveSessions().has(CARD_B)).toBe(true);
431
- expect(getActiveSessions().has(CARD_C)).toBe(true);
432
- expect(client.endAgentSession).toHaveBeenCalledTimes(1);
433
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
434
- status: "completed",
435
- });
436
- });
437
-
438
- test("checkInactivity is a no-op when no client is available", () => {
439
- // No initAutoSession — clientGetter is null
440
- getActiveSessions().set(CARD_A, {
441
- cardId: CARD_A,
442
- startedAt: 0,
443
- lastActivityAt: 0,
444
- isExplicit: false,
445
- agentIdentifier: "unknown",
446
- agentName: "Unknown Agent",
447
- });
448
-
449
- // Should not throw
450
- checkInactivity();
451
-
452
- // Session should still be there (can't end without client)
453
- expect(getActiveSessions().has(CARD_A)).toBe(true);
454
- });
455
-
456
- test("activity resets the inactivity timer", async () => {
457
- const client = makeMockClient();
458
- const endCb = mock(async () => {});
459
- initAutoSession(endCb, () => client as any);
460
-
461
- // Start with old lastActivityAt
462
- getActiveSessions().set(CARD_A, {
463
- cardId: CARD_A,
464
- startedAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
465
- lastActivityAt: Date.now() - INACTIVITY_TIMEOUT_MS - 5000,
466
- isExplicit: false,
467
- agentIdentifier: "unknown",
468
- agentName: "Unknown Agent",
469
- });
470
-
471
- // Refresh activity
472
- await trackActivity(CARD_A, { autoStart: false });
473
-
474
- // Now check — should NOT be timed out
475
- checkInactivity();
476
- await new Promise((r) => setTimeout(r, 50));
477
-
478
- expect(getActiveSessions().has(CARD_A)).toBe(true);
479
- expect(client.endAgentSession).not.toHaveBeenCalled();
480
- });
481
- });
482
-
483
- // ─── markExplicit ───────────────────────────────────────────────────
484
-
485
- describe("markExplicit", () => {
486
- test("marks existing auto-session as explicit", async () => {
487
- const client = makeMockClient();
488
- initAutoSession(
489
- mock(async () => {}),
490
- () => client as any,
491
- );
492
-
493
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
494
- expect(getActiveSessions().get(CARD_A)!.isExplicit).toBe(false);
495
-
496
- markExplicit(CARD_A);
497
- expect(getActiveSessions().get(CARD_A)!.isExplicit).toBe(true);
498
- // agentIdentifier should remain "unknown" since no clientInfo was provided
499
- expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("unknown");
500
- });
501
-
502
- test("creates new tracking entry for unknown card", () => {
503
- initAutoSession(
504
- mock(async () => {}),
505
- () => makeMockClient() as any,
506
- );
507
-
508
- markExplicit(CARD_A);
509
-
510
- expect(getActiveSessions().has(CARD_A)).toBe(true);
511
- expect(getActiveSessions().get(CARD_A)!.isExplicit).toBe(true);
512
- expect(getActiveSessions().get(CARD_A)!.agentIdentifier).toBe("explicit");
513
- });
514
-
515
- test("double markExplicit is idempotent", async () => {
516
- const client = makeMockClient();
517
- initAutoSession(
518
- mock(async () => {}),
519
- () => client as any,
520
- );
521
-
522
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
523
- markExplicit(CARD_A);
524
- markExplicit(CARD_A);
525
-
526
- expect(getActiveSessions().get(CARD_A)!.isExplicit).toBe(true);
527
- expect(getActiveSessions().size).toBe(1);
528
- });
529
- });
530
-
531
- // ─── untrack ────────────────────────────────────────────────────────
532
-
533
- describe("untrack", () => {
534
- test("removes session from tracking", async () => {
535
- const client = makeMockClient();
536
- initAutoSession(
537
- mock(async () => {}),
538
- () => client as any,
539
- );
540
-
541
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
542
- expect(getActiveSessions().has(CARD_A)).toBe(true);
543
-
544
- untrack(CARD_A);
545
- expect(getActiveSessions().has(CARD_A)).toBe(false);
546
- });
547
-
548
- test("is a no-op for non-existent cardId", () => {
549
- initAutoSession(
550
- mock(async () => {}),
551
- () => makeMockClient() as any,
552
- );
553
-
554
- // Should not throw
555
- untrack(CARD_A);
556
- expect(getActiveSessions().size).toBe(0);
557
- });
558
-
559
- test("removes explicit sessions too", () => {
560
- initAutoSession(
561
- mock(async () => {}),
562
- () => makeMockClient() as any,
563
- );
564
-
565
- markExplicit(CARD_A);
566
- expect(getActiveSessions().has(CARD_A)).toBe(true);
567
-
568
- untrack(CARD_A);
569
- expect(getActiveSessions().has(CARD_A)).toBe(false);
570
- });
571
- });
572
-
573
- // ─── shutdown ───────────────────────────────────────────────────────
574
-
575
- describe("shutdown", () => {
576
- test("ends all active sessions with paused status", async () => {
577
- const client = makeMockClient();
578
- const endCb = mock(async () => {});
579
- initAutoSession(endCb, () => client as any);
580
-
581
- getActiveSessions().set(CARD_A, {
582
- cardId: CARD_A,
583
- startedAt: Date.now(),
584
- lastActivityAt: Date.now(),
585
- isExplicit: false,
586
- agentIdentifier: "unknown",
587
- agentName: "Unknown Agent",
588
- });
589
-
590
- await shutdownAllSessions();
591
-
592
- expect(client.endAgentSession).toHaveBeenCalledWith(CARD_A, {
593
- status: "paused",
594
- });
595
- expect(endCb).toHaveBeenCalledWith(client, CARD_A, "paused");
596
- expect(getActiveSessions().size).toBe(0);
597
- });
598
-
599
- test("ends multiple sessions including explicit ones", async () => {
600
- const client = makeMockClient();
601
- const endCb = mock(async () => {});
602
- initAutoSession(endCb, () => client as any);
603
-
604
- getActiveSessions().set(CARD_A, {
605
- cardId: CARD_A,
606
- startedAt: Date.now(),
607
- lastActivityAt: Date.now(),
608
- isExplicit: false,
609
- agentIdentifier: "unknown",
610
- agentName: "Unknown Agent",
611
- });
612
- getActiveSessions().set(CARD_B, {
613
- cardId: CARD_B,
614
- startedAt: Date.now(),
615
- lastActivityAt: Date.now(),
616
- isExplicit: true,
617
- agentIdentifier: "explicit",
618
- agentName: "Explicit Agent",
619
- });
620
-
621
- await shutdownAllSessions();
622
-
623
- // Both should be ended on shutdown (even explicit)
624
- expect(client.endAgentSession).toHaveBeenCalledTimes(2);
625
- expect(endCb).toHaveBeenCalledTimes(2);
626
- expect(getActiveSessions().size).toBe(0);
627
- });
628
-
629
- test("is a no-op when no client is available", async () => {
630
- // No initAutoSession called
631
- getActiveSessions().set(CARD_A, {
632
- cardId: CARD_A,
633
- startedAt: Date.now(),
634
- lastActivityAt: Date.now(),
635
- isExplicit: false,
636
- agentIdentifier: "unknown",
637
- agentName: "Unknown Agent",
638
- });
639
-
640
- // Should not throw
641
- await shutdownAllSessions();
642
-
643
- // Session still tracked (couldn't end without client)
644
- expect(getActiveSessions().has(CARD_A)).toBe(true);
645
- });
646
-
647
- test("is resilient when endAgentSession throws for some sessions", async () => {
648
- let callCount = 0;
649
- const client = makeMockClient();
650
- client.endAgentSession = mock(async () => {
651
- callCount++;
652
- if (callCount === 1) throw new Error("Network error");
653
- return { session: { id: "sess-1" } };
654
- });
655
- const endCb = mock(async () => {});
656
- initAutoSession(endCb, () => client as any);
657
-
658
- getActiveSessions().set(CARD_A, {
659
- cardId: CARD_A,
660
- startedAt: Date.now(),
661
- lastActivityAt: Date.now(),
662
- isExplicit: false,
663
- agentIdentifier: "unknown",
664
- agentName: "Unknown Agent",
665
- });
666
- getActiveSessions().set(CARD_B, {
667
- cardId: CARD_B,
668
- startedAt: Date.now(),
669
- lastActivityAt: Date.now(),
670
- isExplicit: false,
671
- agentIdentifier: "unknown",
672
- agentName: "Unknown Agent",
673
- });
674
-
675
- await shutdownAllSessions();
676
-
677
- // Both should be removed from tracking despite first API error
678
- expect(getActiveSessions().size).toBe(0);
679
- // Callback should still be called for both
680
- expect(endCb).toHaveBeenCalledTimes(2);
681
- });
682
-
683
- test("empty sessions map is a no-op", async () => {
684
- const client = makeMockClient();
685
- initAutoSession(
686
- mock(async () => {}),
687
- () => client as any,
688
- );
689
-
690
- await shutdownAllSessions();
691
-
692
- expect(client.endAgentSession).not.toHaveBeenCalled();
693
- });
694
- });
695
-
696
- // ─── destroyAutoSession ─────────────────────────────────────────────
697
-
698
- describe("destroyAutoSession", () => {
699
- test("clears all state", async () => {
700
- const client = makeMockClient();
701
- initAutoSession(
702
- mock(async () => {}),
703
- () => client as any,
704
- );
705
-
706
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
707
- expect(getActiveSessions().size).toBe(1);
708
-
709
- destroyAutoSession();
710
-
711
- expect(getActiveSessions().size).toBe(0);
712
- });
713
-
714
- test("calling destroyAutoSession twice is safe", () => {
715
- initAutoSession(
716
- mock(async () => {}),
717
- () => makeMockClient() as any,
718
- );
719
- destroyAutoSession();
720
- destroyAutoSession();
721
- expect(getActiveSessions().size).toBe(0);
722
- });
723
-
724
- test("re-initialization after destroy works", async () => {
725
- const client = makeMockClient();
726
- initAutoSession(
727
- mock(async () => {}),
728
- () => client as any,
729
- );
730
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
731
- destroyAutoSession();
732
-
733
- // Re-init
734
- const client2 = makeMockClient();
735
- initAutoSession(
736
- mock(async () => {}),
737
- () => client2 as any,
738
- );
739
- await trackActivity(CARD_B, { autoStart: true, client: client2 as any });
740
-
741
- expect(getActiveSessions().size).toBe(1);
742
- expect(getActiveSessions().has(CARD_B)).toBe(true);
743
- expect(client2.startAgentSession).toHaveBeenCalledTimes(1);
744
- });
745
- });
746
-
747
- // ─── AUTO_START_TRIGGERS ────────────────────────────────────────────
748
-
749
- describe("AUTO_START_TRIGGERS", () => {
750
- test("contains all card-mutating tools", () => {
751
- const expected = [
752
- "harmony_generate_prompt",
753
- "harmony_update_card",
754
- "harmony_move_card",
755
- "harmony_create_subtask",
756
- "harmony_toggle_subtask",
757
- "harmony_add_label_to_card",
758
- "harmony_remove_label_from_card",
759
- ];
760
- for (const tool of expected) {
761
- expect(AUTO_START_TRIGGERS.has(tool)).toBe(true);
762
- }
763
- expect(AUTO_START_TRIGGERS.size).toBe(expected.length);
764
- });
765
-
766
- test("does NOT contain read-only tools", () => {
767
- const readOnly = [
768
- "harmony_get_card",
769
- "harmony_get_card_by_short_id",
770
- "harmony_get_board",
771
- "harmony_search_cards",
772
- "harmony_get_agent_session",
773
- "harmony_get_card_links",
774
- "harmony_list_workspaces",
775
- "harmony_list_projects",
776
- "harmony_get_context",
777
- ];
778
- for (const tool of readOnly) {
779
- expect(AUTO_START_TRIGGERS.has(tool)).toBe(false);
780
- }
781
- });
782
-
783
- test("does NOT contain session management tools", () => {
784
- expect(AUTO_START_TRIGGERS.has("harmony_start_agent_session")).toBe(false);
785
- expect(AUTO_START_TRIGGERS.has("harmony_end_agent_session")).toBe(false);
786
- expect(AUTO_START_TRIGGERS.has("harmony_update_agent_progress")).toBe(
787
- false,
788
- );
789
- });
790
- });
791
-
792
- // ─── INACTIVITY_TIMEOUT_MS ──────────────────────────────────────────
793
-
794
- describe("INACTIVITY_TIMEOUT_MS", () => {
795
- test("is 10 minutes", () => {
796
- expect(INACTIVITY_TIMEOUT_MS).toBe(10 * 60 * 1000);
797
- });
798
- });
799
-
800
- // ─── Edge cases / race conditions ───────────────────────────────────
801
-
802
- describe("edge cases", () => {
803
- test("trackActivity before initAutoSession uses client from options", async () => {
804
- // No initAutoSession called
805
- const client = makeMockClient();
806
-
807
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
808
-
809
- expect(client.startAgentSession).toHaveBeenCalledTimes(1);
810
- expect(getActiveSessions().size).toBe(1);
811
- });
812
-
813
- test("re-initialization clears old interval timer", async () => {
814
- const client1 = makeMockClient();
815
- const endCb1 = mock(async () => {});
816
- initAutoSession(endCb1, () => client1 as any);
817
-
818
- // Re-initialize
819
- const client2 = makeMockClient();
820
- const endCb2 = mock(async () => {});
821
- initAutoSession(endCb2, () => client2 as any);
822
-
823
- // Add stale session and trigger check
824
- getActiveSessions().set(CARD_A, {
825
- cardId: CARD_A,
826
- startedAt: 0,
827
- lastActivityAt: 0,
828
- isExplicit: false,
829
- agentIdentifier: "unknown",
830
- agentName: "Unknown Agent",
831
- });
832
-
833
- checkInactivity();
834
- await new Promise((r) => setTimeout(r, 50));
835
-
836
- // Should use client2, not client1
837
- expect(client2.endAgentSession).toHaveBeenCalledTimes(1);
838
- expect(client1.endAgentSession).not.toHaveBeenCalled();
839
- });
840
-
841
- test("endCallback throwing does not prevent session removal", async () => {
842
- const client = makeMockClient();
843
- const endCb = mock(async () => {
844
- throw new Error("Callback crash");
845
- });
846
- initAutoSession(endCb, () => client as any);
847
-
848
- getActiveSessions().set(CARD_A, {
849
- cardId: CARD_A,
850
- startedAt: 0,
851
- lastActivityAt: 0,
852
- isExplicit: false,
853
- agentIdentifier: "unknown",
854
- agentName: "Unknown Agent",
855
- });
856
-
857
- checkInactivity();
858
- await new Promise((r) => setTimeout(r, 50));
859
-
860
- // Session should be removed despite callback error
861
- expect(getActiveSessions().has(CARD_A)).toBe(false);
862
- });
863
-
864
- test("both endAgentSession and endCallback throwing still cleans up", async () => {
865
- const client = makeMockClient();
866
- client.endAgentSession = mock(async () => {
867
- throw new Error("API down");
868
- });
869
- const endCb = mock(async () => {
870
- throw new Error("Pipeline crash");
871
- });
872
- initAutoSession(endCb, () => client as any);
873
-
874
- await trackActivity(CARD_A, { autoStart: true, client: client as any });
875
- await trackActivity(CARD_B, { autoStart: true, client: client as any });
876
-
877
- // Despite both throwing, tracking should be clean
878
- expect(getActiveSessions().has(CARD_A)).toBe(false);
879
- expect(getActiveSessions().has(CARD_B)).toBe(true);
880
- });
881
-
882
- test("concurrent auto-start on same card only starts once", async () => {
883
- const client = makeMockClient();
884
- // Slow startAgentSession to create a window for concurrent calls
885
- client.startAgentSession = mock(
886
- async () =>
887
- new Promise((resolve) =>
888
- setTimeout(() => resolve({ session: { id: "sess-1" } }), 20),
889
- ),
890
- );
891
- initAutoSession(
892
- mock(async () => {}),
893
- () => client as any,
894
- );
895
-
896
- // Fire two concurrent auto-starts for the same card
897
- const p1 = trackActivity(CARD_A, {
898
- autoStart: true,
899
- client: client as any,
900
- });
901
- const p2 = trackActivity(CARD_A, {
902
- autoStart: true,
903
- client: client as any,
904
- });
905
- await Promise.all([p1, p2]);
906
-
907
- // First call creates session, second call hits early return (existing)
908
- // but due to async, both might race. At minimum, session should exist once.
909
- expect(getActiveSessions().size).toBe(1);
910
- expect(getActiveSessions().has(CARD_A)).toBe(true);
911
- });
912
- });