@sjawhar/opencode-legion-envoy 0.1.10 → 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.
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ dispatchSubscriptionTopic,
4
+ dispatchThreadTopic,
5
+ isDispatchTool,
6
+ } from "../dispatch-subscribe";
7
+
8
+ describe("isDispatchTool", () => {
9
+ it("matches the MCP-exposed name and common separators", () => {
10
+ expect(isDispatchTool("envoy_dispatch")).toBe(true);
11
+ expect(isDispatchTool("dispatch")).toBe(true);
12
+ expect(isDispatchTool("envoy.dispatch")).toBe(true);
13
+ expect(isDispatchTool("mcp__envoy__dispatch")).toBe(true);
14
+ });
15
+
16
+ it("does not match unrelated tools", () => {
17
+ expect(isDispatchTool("envoy_subscribe")).toBe(false);
18
+ expect(isDispatchTool("bash")).toBe(false);
19
+ expect(isDispatchTool("dispatcher")).toBe(false);
20
+ expect(isDispatchTool("dispatch_thread")).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("dispatchThreadTopic", () => {
25
+ it("builds the wildcard thread topic", () => {
26
+ expect(dispatchThreadTopic("sjawhar", "legion", 123)).toBe(
27
+ "notifications.github.sjawhar.legion.issue.123.>"
28
+ );
29
+ });
30
+ });
31
+
32
+ describe("dispatchSubscriptionTopic", () => {
33
+ it("derives the topic from a dispatch tool result JSON", () => {
34
+ const output = JSON.stringify({
35
+ thread: 742,
36
+ url: "https://github.com/sjawhar/legion/issues/742",
37
+ });
38
+ expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBe(
39
+ "notifications.github.sjawhar.legion.issue.742.>"
40
+ );
41
+ });
42
+
43
+ it("derives owner/repo/number purely from the issue URL", () => {
44
+ // Even if a stale/incorrect JSON `thread` were present, the URL is canonical.
45
+ const output = '{"thread":1,"url":"https://github.com/acme/Widgets/issues/55"}';
46
+ expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBe(
47
+ "notifications.github.acme.Widgets.issue.55.>"
48
+ );
49
+ });
50
+
51
+ it("returns null for non-dispatch tools even with a github URL present", () => {
52
+ const output = '{"url":"https://github.com/sjawhar/legion/issues/9"}';
53
+ expect(dispatchSubscriptionTopic("envoy_subscribe", output)).toBeNull();
54
+ });
55
+
56
+ it("returns null when the output has no github issue URL", () => {
57
+ expect(dispatchSubscriptionTopic("envoy_dispatch", "created thread 5")).toBeNull();
58
+ expect(dispatchSubscriptionTopic("envoy_dispatch", "")).toBeNull();
59
+ expect(dispatchSubscriptionTopic("envoy_dispatch", "not json at all")).toBeNull();
60
+ });
61
+
62
+ it("ignores pull-request URLs (only issue threads carry dispatch replies)", () => {
63
+ const output = '{"url":"https://github.com/sjawhar/legion/pull/100"}';
64
+ expect(dispatchSubscriptionTopic("envoy_dispatch", output)).toBeNull();
65
+ });
66
+ });
@@ -16,7 +16,7 @@ describe("envoy plugin init", () => {
16
16
  process.env.ENVOY_URL = "http://127.0.0.1:59999"; // Non-existent
17
17
 
18
18
  try {
19
- const pluginModule = await import("../index");
19
+ const pluginModule = await import("../server");
20
20
  const initPlugin = pluginModule.default;
21
21
 
22
22
  const start = performance.now();
@@ -45,7 +45,7 @@ describe("envoy plugin init", () => {
45
45
  process.env.ENVOY_URL = "http://127.0.0.1:59999";
46
46
 
47
47
  try {
48
- const pluginModule = await import("../index");
48
+ const pluginModule = await import("../server");
49
49
  const initPlugin = pluginModule.default;
50
50
  const hooks = await initPlugin({ serverUrl: new URL("http://127.0.0.1:13381") } as never);
51
51
 
@@ -77,7 +77,7 @@ describe("envoy_whoami", () => {
77
77
  process.env.HOSTNAME = "test-machine";
78
78
 
79
79
  try {
80
- const pluginModule = await import("../index");
80
+ const pluginModule = await import("../server");
81
81
  const initPlugin = pluginModule.default;
82
82
  const hooks = await initPlugin({
83
83
  serverUrl: new URL("http://127.0.0.1:13381"),
@@ -89,7 +89,7 @@ describe("envoy_whoami", () => {
89
89
  metadata: mock(() => {}),
90
90
  } as never);
91
91
 
92
- const parsed = JSON.parse(result);
92
+ const parsed = JSON.parse(typeof result === "string" ? result : result.output);
93
93
  expect(parsed.session_id).toBe("ses_test_whoami");
94
94
  expect(parsed.machine_id).toBe("test-machine");
95
95
  expect(parsed.dir).toBe("/tmp/test-workspace");
@@ -112,7 +112,7 @@ describe("envoy_whoami", () => {
112
112
  delete process.env.HOSTNAME;
113
113
 
114
114
  try {
115
- const pluginModule = await import("../index");
115
+ const pluginModule = await import("../server");
116
116
  const initPlugin = pluginModule.default;
117
117
  const hooks = await initPlugin({
118
118
  serverUrl: new URL("http://127.0.0.1:13381"),
@@ -124,7 +124,7 @@ describe("envoy_whoami", () => {
124
124
  metadata: mock(() => {}),
125
125
  } as never);
126
126
 
127
- const parsed = JSON.parse(result);
127
+ const parsed = JSON.parse(typeof result === "string" ? result : result.output);
128
128
  expect(parsed.machine_id).toBe("unknown");
129
129
  } finally {
130
130
  process.env.ENVOY_URL = originalEnvoyUrl;
@@ -143,7 +143,7 @@ describe("envoy_sessions", () => {
143
143
  process.env.ENVOY_URL = "http://127.0.0.1:59999";
144
144
 
145
145
  try {
146
- const pluginModule = await import("../index");
146
+ const pluginModule = await import("../server");
147
147
  const initPlugin = pluginModule.default;
148
148
  const hooks = await initPlugin({
149
149
  serverUrl: new URL("http://127.0.0.1:13381"),
@@ -198,7 +198,7 @@ describe("session title", () => {
198
198
  }) as typeof fetch;
199
199
 
200
200
  try {
201
- const pluginModule = await import("../index");
201
+ const pluginModule = await import("../server");
202
202
  const hooks = await pluginModule.default({
203
203
  serverUrl: new URL("http://127.0.0.1:13381/"),
204
204
  } as never);
@@ -230,3 +230,428 @@ describe("session title", () => {
230
230
  }
231
231
  });
232
232
  });
233
+
234
+ describe("heartbeat refreshes all busy sessions (fix 1a)", () => {
235
+ it("re-subscribes every session that has been busy, not just the most recent", async () => {
236
+ const originalEnvoyUrl = process.env.ENVOY_URL;
237
+ const originalHb = process.env.ENVOY_HEARTBEAT_MS;
238
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
239
+ process.env.ENVOY_HEARTBEAT_MS = "40";
240
+
241
+ const subs: { id: string; t: number }[] = [];
242
+ const originalFetch = globalThis.fetch;
243
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
244
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
245
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
246
+ const body = JSON.parse(init.body as string);
247
+ subs.push({ id: body.session_id, t: Date.now() });
248
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
249
+ status: 200,
250
+ headers: { "Content-Type": "application/json" },
251
+ });
252
+ }
253
+ if (url.includes("/v1/sessions")) {
254
+ return new Response(JSON.stringify([]), {
255
+ status: 200,
256
+ headers: { "Content-Type": "application/json" },
257
+ });
258
+ }
259
+ // Serve title lookups -> 404 (no title, avoids follow-up subscribe noise)
260
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
261
+ throw new Error("connection refused");
262
+ }) as typeof fetch;
263
+
264
+ let dispose: (() => void) | undefined;
265
+ try {
266
+ const pluginModule = await import("../server");
267
+ const hooks = await pluginModule.default({
268
+ serverUrl: new URL("http://127.0.0.1:13381/"),
269
+ } as never);
270
+ dispose = (hooks as { dispose?: () => void }).dispose;
271
+
272
+ const busy = (id: string) =>
273
+ hooks.event({
274
+ event: {
275
+ type: "session.status",
276
+ properties: { sessionID: id, status: { type: "busy" } },
277
+ },
278
+ });
279
+ await busy("ses_A");
280
+ await busy("ses_B");
281
+
282
+ // Settle (< one heartbeat tick): capture ses_A's count before heartbeats run
283
+ await new Promise((r) => setTimeout(r, 30));
284
+ const aStart = subs.filter((s) => s.id === "ses_A").length;
285
+
286
+ // ~4 heartbeat ticks at 40ms
287
+ await new Promise((r) => setTimeout(r, 180));
288
+ const aEnd = subs.filter((s) => s.id === "ses_A").length;
289
+ const bEnd = subs.filter((s) => s.id === "ses_B").length;
290
+
291
+ // ses_A is now idle (ses_B is the most-recently-busy). The heartbeat must
292
+ // keep refreshing ses_A's registration, not only ses_B's.
293
+ expect(aEnd).toBeGreaterThan(aStart);
294
+ expect(bEnd).toBeGreaterThan(1);
295
+ } finally {
296
+ dispose?.();
297
+ globalThis.fetch = originalFetch;
298
+ process.env.ENVOY_URL = originalEnvoyUrl;
299
+ if (originalHb === undefined) delete process.env.ENVOY_HEARTBEAT_MS;
300
+ else process.env.ENVOY_HEARTBEAT_MS = originalHb;
301
+ }
302
+ });
303
+ });
304
+
305
+ describe("re-adopts sibling sessions after serve restart (fix 1b)", () => {
306
+ it("registers idle same-dir+machine siblings on first activity, ignoring other machines/dirs", async () => {
307
+ const originalEnvoyUrl = process.env.ENVOY_URL;
308
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
309
+ const cwd = process.cwd();
310
+
311
+ const subscribed: string[] = [];
312
+ const originalFetch = globalThis.fetch;
313
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
314
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
315
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
316
+ const body = JSON.parse(init.body as string);
317
+ subscribed.push(body.session_id);
318
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
319
+ status: 200,
320
+ headers: { "Content-Type": "application/json" },
321
+ });
322
+ }
323
+ if (url.includes("/v1/sessions")) {
324
+ return new Response(
325
+ JSON.stringify([
326
+ {
327
+ session_id: "ses_active",
328
+ machine_id: "M",
329
+ dir: cwd,
330
+ port: 13381,
331
+ title: "",
332
+ topics: [],
333
+ updated_at: Date.now(),
334
+ },
335
+ {
336
+ session_id: "ses_idle",
337
+ machine_id: "M",
338
+ dir: cwd,
339
+ port: 9,
340
+ title: "Idle",
341
+ topics: [],
342
+ updated_at: Date.now(),
343
+ },
344
+ {
345
+ session_id: "ses_foreign",
346
+ machine_id: "OTHER",
347
+ dir: cwd,
348
+ port: 7,
349
+ title: "",
350
+ topics: [],
351
+ updated_at: Date.now(),
352
+ },
353
+ {
354
+ session_id: "ses_otherdir",
355
+ machine_id: "M",
356
+ dir: "/somewhere/else",
357
+ port: 8,
358
+ title: "",
359
+ topics: [],
360
+ updated_at: Date.now(),
361
+ },
362
+ ]),
363
+ { status: 200, headers: { "Content-Type": "application/json" } }
364
+ );
365
+ }
366
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
367
+ throw new Error("connection refused");
368
+ }) as typeof fetch;
369
+
370
+ try {
371
+ const pluginModule = await import("../server");
372
+ const hooks = await pluginModule.default({
373
+ serverUrl: new URL("http://127.0.0.1:13381/"),
374
+ } as never);
375
+
376
+ await hooks.event({
377
+ event: {
378
+ type: "session.status",
379
+ properties: { sessionID: "ses_active", status: { type: "busy" } },
380
+ },
381
+ });
382
+ await new Promise((r) => setTimeout(r, 100));
383
+
384
+ expect(subscribed).toContain("ses_active");
385
+ expect(subscribed).toContain("ses_idle");
386
+ expect(subscribed).not.toContain("ses_foreign");
387
+ expect(subscribed).not.toContain("ses_otherdir");
388
+ } finally {
389
+ globalThis.fetch = originalFetch;
390
+ process.env.ENVOY_URL = originalEnvoyUrl;
391
+ }
392
+ });
393
+ });
394
+
395
+ describe("prunes deleted sessions from the heartbeat (fix 2)", () => {
396
+ it("stops re-subscribing a session after session.deleted", async () => {
397
+ const originalEnvoyUrl = process.env.ENVOY_URL;
398
+ const originalHb = process.env.ENVOY_HEARTBEAT_MS;
399
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
400
+ process.env.ENVOY_HEARTBEAT_MS = "40";
401
+
402
+ const subs: string[] = [];
403
+ const originalFetch = globalThis.fetch;
404
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
405
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
406
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
407
+ const body = JSON.parse(init.body as string);
408
+ subs.push(body.session_id);
409
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
410
+ status: 200,
411
+ headers: { "Content-Type": "application/json" },
412
+ });
413
+ }
414
+ if (url.includes("/v1/interests/unsubscribe") || url.includes("/v1/sessions")) {
415
+ return new Response(JSON.stringify([]), {
416
+ status: 200,
417
+ headers: { "Content-Type": "application/json" },
418
+ });
419
+ }
420
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
421
+ throw new Error("connection refused");
422
+ }) as typeof fetch;
423
+
424
+ let dispose: (() => void) | undefined;
425
+ try {
426
+ const pluginModule = await import("../server");
427
+ const hooks = await pluginModule.default({
428
+ serverUrl: new URL("http://127.0.0.1:13381/"),
429
+ } as never);
430
+ dispose = (hooks as { dispose?: () => void }).dispose;
431
+ const busy = (id: string) =>
432
+ hooks.event({
433
+ event: {
434
+ type: "session.status",
435
+ properties: { sessionID: id, status: { type: "busy" } },
436
+ },
437
+ });
438
+ await busy("ses_A");
439
+ await busy("ses_B");
440
+
441
+ await hooks.event({ event: { type: "session.deleted", properties: { sessionID: "ses_A" } } });
442
+ // Let any in-flight heartbeat settle, then mark counts.
443
+ await new Promise((r) => setTimeout(r, 60));
444
+ const aMark = subs.filter((s) => s === "ses_A").length;
445
+ const bMark = subs.filter((s) => s === "ses_B").length;
446
+
447
+ await new Promise((r) => setTimeout(r, 160));
448
+ const aEnd = subs.filter((s) => s === "ses_A").length;
449
+ const bEnd = subs.filter((s) => s === "ses_B").length;
450
+
451
+ // ses_A was deleted -> heartbeat must stop refreshing it.
452
+ expect(aEnd).toBe(aMark);
453
+ // ses_B is still alive -> heartbeat keeps refreshing it.
454
+ expect(bEnd).toBeGreaterThan(bMark);
455
+ } finally {
456
+ dispose?.();
457
+ globalThis.fetch = originalFetch;
458
+ process.env.ENVOY_URL = originalEnvoyUrl;
459
+ if (originalHb === undefined) delete process.env.ENVOY_HEARTBEAT_MS;
460
+ else process.env.ENVOY_HEARTBEAT_MS = originalHb;
461
+ }
462
+ });
463
+ });
464
+
465
+ describe("re-adoption retries until the registry shows our own session (fix 3)", () => {
466
+ it("adopts an idle sibling once /v1/sessions includes self on a later poll", async () => {
467
+ const originalEnvoyUrl = process.env.ENVOY_URL;
468
+ const originalHb = process.env.ENVOY_HEARTBEAT_MS;
469
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
470
+ process.env.ENVOY_HEARTBEAT_MS = "40";
471
+ const cwd = process.cwd();
472
+
473
+ let sessionsCalls = 0;
474
+ const subscribed: string[] = [];
475
+ const originalFetch = globalThis.fetch;
476
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
477
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
478
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
479
+ const body = JSON.parse(init.body as string);
480
+ subscribed.push(body.session_id);
481
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
482
+ status: 200,
483
+ headers: { "Content-Type": "application/json" },
484
+ });
485
+ }
486
+ if (url.includes("/v1/sessions")) {
487
+ sessionsCalls += 1;
488
+ // First poll: self not persisted yet. Later polls: self + idle sibling present.
489
+ const body =
490
+ sessionsCalls <= 1
491
+ ? []
492
+ : [
493
+ {
494
+ session_id: "ses_active",
495
+ machine_id: "M",
496
+ dir: cwd,
497
+ port: 13381,
498
+ title: "",
499
+ topics: [],
500
+ updated_at: Date.now(),
501
+ },
502
+ {
503
+ session_id: "ses_idle",
504
+ machine_id: "M",
505
+ dir: cwd,
506
+ port: 9,
507
+ title: "Idle",
508
+ topics: [],
509
+ updated_at: Date.now(),
510
+ },
511
+ ];
512
+ return new Response(JSON.stringify(body), {
513
+ status: 200,
514
+ headers: { "Content-Type": "application/json" },
515
+ });
516
+ }
517
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
518
+ throw new Error("connection refused");
519
+ }) as typeof fetch;
520
+
521
+ let dispose: (() => void) | undefined;
522
+ try {
523
+ const pluginModule = await import("../server");
524
+ const hooks = await pluginModule.default({
525
+ serverUrl: new URL("http://127.0.0.1:13381/"),
526
+ } as never);
527
+ dispose = (hooks as { dispose?: () => void }).dispose;
528
+ await hooks.event({
529
+ event: {
530
+ type: "session.status",
531
+ properties: { sessionID: "ses_active", status: { type: "busy" } },
532
+ },
533
+ });
534
+ await new Promise((r) => setTimeout(r, 200));
535
+
536
+ expect(subscribed).toContain("ses_idle");
537
+ } finally {
538
+ dispose?.();
539
+ globalThis.fetch = originalFetch;
540
+ process.env.ENVOY_URL = originalEnvoyUrl;
541
+ if (originalHb === undefined) delete process.env.ENVOY_HEARTBEAT_MS;
542
+ else process.env.ENVOY_HEARTBEAT_MS = originalHb;
543
+ }
544
+ });
545
+ });
546
+
547
+ describe("invalid ENVOY_HEARTBEAT_MS falls back to the default (fix 6)", () => {
548
+ it("does not hammer subscribe when the env value is negative", async () => {
549
+ const originalEnvoyUrl = process.env.ENVOY_URL;
550
+ const originalHb = process.env.ENVOY_HEARTBEAT_MS;
551
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
552
+ process.env.ENVOY_HEARTBEAT_MS = "-5";
553
+
554
+ const subs: string[] = [];
555
+ const originalFetch = globalThis.fetch;
556
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
557
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
558
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
559
+ const body = JSON.parse(init.body as string);
560
+ subs.push(body.session_id);
561
+ return new Response(JSON.stringify({ session_id: body.session_id, topics: [] }), {
562
+ status: 200,
563
+ headers: { "Content-Type": "application/json" },
564
+ });
565
+ }
566
+ if (url.includes("/v1/sessions")) {
567
+ return new Response(JSON.stringify([]), {
568
+ status: 200,
569
+ headers: { "Content-Type": "application/json" },
570
+ });
571
+ }
572
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
573
+ throw new Error("connection refused");
574
+ }) as typeof fetch;
575
+
576
+ try {
577
+ const pluginModule = await import("../server");
578
+ const hooks = await pluginModule.default({
579
+ serverUrl: new URL("http://127.0.0.1:13381/"),
580
+ } as never);
581
+ await hooks.event({
582
+ event: {
583
+ type: "session.status",
584
+ properties: { sessionID: "ses_A", status: { type: "busy" } },
585
+ },
586
+ });
587
+ await new Promise((r) => setTimeout(r, 200));
588
+
589
+ // A negative interval must NOT be honored (would hammer); only the initial
590
+ // subscribe should have happened within this window.
591
+ expect(subs.filter((s) => s === "ses_A").length).toBe(1);
592
+ } finally {
593
+ globalThis.fetch = originalFetch;
594
+ process.env.ENVOY_URL = originalEnvoyUrl;
595
+ if (originalHb === undefined) delete process.env.ENVOY_HEARTBEAT_MS;
596
+ else process.env.ENVOY_HEARTBEAT_MS = originalHb;
597
+ }
598
+ });
599
+ });
600
+
601
+ describe("tool.execute.after auto-subscribes the caller to dispatch threads (AC#4)", () => {
602
+ async function runHook(tool: string, output: string): Promise<string[][]> {
603
+ const originalEnvoyUrl = process.env.ENVOY_URL;
604
+ process.env.ENVOY_URL = "http://127.0.0.1:59999";
605
+ const subscribed: string[][] = [];
606
+ const originalFetch = globalThis.fetch;
607
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
608
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
609
+ if (url.includes("/v1/interests/subscribe") && init?.body) {
610
+ const body = JSON.parse(init.body as string) as { session_id: string; topics: string[] };
611
+ subscribed.push([body.session_id, ...body.topics]);
612
+ return new Response(JSON.stringify({ topics: [] }), {
613
+ status: 200,
614
+ headers: { "Content-Type": "application/json" },
615
+ });
616
+ }
617
+ if (url.includes("/session/")) return new Response("not found", { status: 404 });
618
+ return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } });
619
+ }) as typeof fetch;
620
+ try {
621
+ const pluginModule = await import("../server");
622
+ const hooks = await pluginModule.default({
623
+ serverUrl: new URL("http://127.0.0.1:13381/"),
624
+ } as never);
625
+ const after = hooks["tool.execute.after"];
626
+ expect(after).toBeDefined();
627
+ await after?.(
628
+ { tool, sessionID: "ses_dispatch", callID: "call_1", args: {} },
629
+ { title: "Dispatch", output, metadata: {} }
630
+ );
631
+ return subscribed;
632
+ } finally {
633
+ globalThis.fetch = originalFetch;
634
+ process.env.ENVOY_URL = originalEnvoyUrl;
635
+ }
636
+ }
637
+
638
+ it("subscribes the calling session to the new thread's GitHub topic", async () => {
639
+ const output = JSON.stringify({
640
+ thread: 742,
641
+ url: "https://github.com/sjawhar/legion/issues/742",
642
+ });
643
+ const subscribed = await runHook("envoy_dispatch", output);
644
+ expect(subscribed).toContainEqual([
645
+ "ses_dispatch",
646
+ "notifications.github.sjawhar.legion.issue.742.>",
647
+ ]);
648
+ });
649
+
650
+ it("does not subscribe for unrelated tools", async () => {
651
+ const output = JSON.stringify({
652
+ url: "https://github.com/sjawhar/legion/issues/9",
653
+ });
654
+ const subscribed = await runHook("envoy_subscribe", output);
655
+ expect(subscribed.length).toBe(0);
656
+ });
657
+ });