@calltelemetry/openclaw-linear 0.7.1 → 0.8.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,584 @@
1
+ /**
2
+ * E2E dispatch pipeline tests.
3
+ *
4
+ * Exercises the real pipeline chain: spawnWorker → triggerAudit → processVerdict
5
+ * → handleAuditPass/handleAuditFail, with file-backed dispatch-state, real
6
+ * artifact writes, real notification formatting, and DAG cascade.
7
+ *
8
+ * Only external boundaries are mocked: runAgent, LinearAgentApi, codex-worktree.
9
+ */
10
+ import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
11
+ import { mkdtempSync, readFileSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Mocks — external boundaries only (vi.hoisted + vi.mock)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const { runAgentMock } = vi.hoisted(() => ({
20
+ runAgentMock: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("../agent/agent.js", () => ({
24
+ runAgent: runAgentMock,
25
+ }));
26
+
27
+ vi.mock("../agent/watchdog.js", () => ({
28
+ resolveWatchdogConfig: vi.fn(() => ({
29
+ inactivityMs: 120_000,
30
+ maxTotalMs: 7_200_000,
31
+ toolTimeoutMs: 600_000,
32
+ })),
33
+ }));
34
+
35
+ vi.mock("../infra/codex-worktree.js", () => ({
36
+ createWorktree: vi.fn(),
37
+ prepareWorkspace: vi.fn(),
38
+ }));
39
+
40
+ vi.mock("../api/linear-api.js", () => ({
41
+ LinearAgentApi: class {},
42
+ resolveLinearToken: vi.fn().mockReturnValue({ accessToken: "test-token", source: "env" }),
43
+ }));
44
+
45
+ vi.mock("../infra/observability.js", () => ({
46
+ emitDiagnostic: vi.fn(),
47
+ }));
48
+
49
+ vi.mock("openclaw/plugin-sdk", () => ({}));
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Imports (AFTER mocks)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ import { spawnWorker, clearPromptCache, type HookContext } from "./pipeline.js";
56
+ import { registerDispatch, readDispatchState, type ActiveDispatch } from "./dispatch-state.js";
57
+ import { writeProjectDispatch, readProjectDispatch, type ProjectDispatchState, type ProjectIssueStatus } from "./dag-dispatch.js";
58
+ import { createMockLinearApi, type MockLinearApi } from "../__test__/helpers.js";
59
+ import { makeIssueDetails } from "../__test__/fixtures/linear-responses.js";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function tmpDir(): string {
66
+ return mkdtempSync(join(tmpdir(), "claw-e2e-"));
67
+ }
68
+
69
+ function makeHookCtx(opts?: {
70
+ configPath?: string;
71
+ linearApi?: MockLinearApi;
72
+ pluginConfig?: Record<string, unknown>;
73
+ notifyFn?: Mock;
74
+ }): HookContext & { mockLinearApi: MockLinearApi; notifyCalls: Array<[string, unknown]> } {
75
+ const configPath = opts?.configPath ?? join(tmpDir(), "state.json");
76
+ const mockLinearApi = opts?.linearApi ?? createMockLinearApi();
77
+ const notifyCalls: Array<[string, unknown]> = [];
78
+ const notifyFn = opts?.notifyFn ?? vi.fn(async (kind: string, payload: unknown) => {
79
+ notifyCalls.push([kind, payload]);
80
+ });
81
+
82
+ return {
83
+ api: {
84
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
85
+ pluginConfig: opts?.pluginConfig ?? {},
86
+ runtime: {},
87
+ } as any,
88
+ linearApi: mockLinearApi as any,
89
+ notify: notifyFn,
90
+ pluginConfig: opts?.pluginConfig ?? {},
91
+ configPath,
92
+ mockLinearApi,
93
+ notifyCalls,
94
+ };
95
+ }
96
+
97
+ function makeDispatch(worktreePath: string, overrides?: Partial<ActiveDispatch>): ActiveDispatch {
98
+ return {
99
+ issueId: "issue-1",
100
+ issueIdentifier: "ENG-100",
101
+ worktreePath,
102
+ branch: "codex/ENG-100",
103
+ tier: "junior" as const,
104
+ model: "test-model",
105
+ status: "dispatched",
106
+ dispatchedAt: new Date().toISOString(),
107
+ attempt: 0,
108
+ ...overrides,
109
+ };
110
+ }
111
+
112
+ function passVerdict(criteria: string[] = ["tests pass"]) {
113
+ return JSON.stringify({ pass: true, criteria, gaps: [], testResults: "ok" });
114
+ }
115
+
116
+ function failVerdict(gaps: string[] = ["missing tests"], criteria: string[] = []) {
117
+ return JSON.stringify({ pass: false, criteria, gaps, testResults: "failed" });
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Tests
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("E2E dispatch pipeline", () => {
125
+ let worktree: string;
126
+
127
+ beforeEach(() => {
128
+ vi.clearAllMocks();
129
+ clearPromptCache();
130
+ worktree = tmpDir();
131
+ });
132
+
133
+ // =========================================================================
134
+ // Test 1: Happy path — dispatch → working → auditing → done
135
+ // =========================================================================
136
+ it("happy path: dispatch → audit pass → done", async () => {
137
+ const hookCtx = makeHookCtx();
138
+ const dispatch = makeDispatch(worktree);
139
+
140
+ // Register the dispatch in state
141
+ await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
142
+
143
+ // Mock linearApi.getIssueDetails for pipeline
144
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
145
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
146
+ );
147
+
148
+ // runAgent: worker returns text, then audit returns pass verdict
149
+ let callCount = 0;
150
+ runAgentMock.mockImplementation(async () => {
151
+ callCount++;
152
+ if (callCount === 1) {
153
+ // Worker
154
+ return { success: true, output: "Implemented the fix and added tests.", watchdogKilled: false };
155
+ }
156
+ // Audit
157
+ return { success: true, output: passVerdict(), watchdogKilled: false };
158
+ });
159
+
160
+ await spawnWorker(hookCtx, dispatch);
161
+
162
+ // Verify state transitions: dispatched → working → auditing → done → completed
163
+ const state = await readDispatchState(hookCtx.configPath);
164
+ expect(state.dispatches.active["ENG-100"]).toBeUndefined(); // moved to completed
165
+ expect(state.dispatches.completed["ENG-100"]).toBeDefined();
166
+ expect(state.dispatches.completed["ENG-100"].status).toBe("done");
167
+
168
+ // Verify notify events
169
+ const notifyKinds = hookCtx.notifyCalls.map(([kind]) => kind);
170
+ expect(notifyKinds).toContain("working");
171
+ expect(notifyKinds).toContain("auditing");
172
+ expect(notifyKinds).toContain("audit_pass");
173
+
174
+ // Verify Linear comment was posted for audit pass
175
+ expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
176
+ "issue-1",
177
+ expect.stringContaining("Done"),
178
+ );
179
+
180
+ // Verify artifacts exist
181
+ const clawDir = join(worktree, ".claw");
182
+ expect(existsSync(join(clawDir, "worker-0.md"))).toBe(true);
183
+ expect(existsSync(join(clawDir, "audit-0.json"))).toBe(true);
184
+ expect(existsSync(join(clawDir, "log.jsonl"))).toBe(true);
185
+ });
186
+
187
+ // =========================================================================
188
+ // Test 2: Rework — audit fail → retry → pass
189
+ // =========================================================================
190
+ it("rework: audit fail → retry → pass", async () => {
191
+ const hookCtx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 2 } });
192
+ const dispatch = makeDispatch(worktree);
193
+
194
+ await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
195
+
196
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
197
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
198
+ );
199
+
200
+ // Call sequence: worker1, audit1(fail), then pipeline transitions to "working"
201
+ // but does NOT re-invoke spawnWorker for rework — it just sets state.
202
+ // So we test the first spawnWorker (fail), then manually call spawnWorker again
203
+ // for the rework flow.
204
+ let callCount = 0;
205
+ runAgentMock.mockImplementation(async () => {
206
+ callCount++;
207
+ if (callCount === 1) return { success: true, output: "First attempt done.", watchdogKilled: false };
208
+ if (callCount === 2) return { success: true, output: failVerdict(["missing tests"]), watchdogKilled: false };
209
+ if (callCount === 3) return { success: true, output: "Reworked: added tests.", watchdogKilled: false };
210
+ return { success: true, output: passVerdict(), watchdogKilled: false };
211
+ });
212
+
213
+ // First run — should fail audit
214
+ await spawnWorker(hookCtx, dispatch);
215
+
216
+ // After fail, state should be "working" with attempt=1
217
+ let state = await readDispatchState(hookCtx.configPath);
218
+ const reworkDispatch = state.dispatches.active["ENG-100"];
219
+ expect(reworkDispatch).toBeDefined();
220
+ expect(reworkDispatch.status).toBe("working");
221
+ expect(reworkDispatch.attempt).toBe(1);
222
+
223
+ // Verify audit_fail notification was sent
224
+ const failNotify = hookCtx.notifyCalls.find(([k]) => k === "audit_fail");
225
+ expect(failNotify).toBeDefined();
226
+
227
+ // Rework comment posted
228
+ expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
229
+ "issue-1",
230
+ expect.stringContaining("Needs More Work"),
231
+ );
232
+
233
+ // Second run (rework) — dispatch is already in "working" state
234
+ await spawnWorker(hookCtx, reworkDispatch, { gaps: ["missing tests"] });
235
+
236
+ // Should now be completed
237
+ state = await readDispatchState(hookCtx.configPath);
238
+ expect(state.dispatches.active["ENG-100"]).toBeUndefined();
239
+ expect(state.dispatches.completed["ENG-100"]).toBeDefined();
240
+ expect(state.dispatches.completed["ENG-100"].status).toBe("done");
241
+
242
+ const passNotify = hookCtx.notifyCalls.find(([k]) => k === "audit_pass");
243
+ expect(passNotify).toBeDefined();
244
+ });
245
+
246
+ // =========================================================================
247
+ // Test 3: Stuck — max rework exceeded
248
+ // =========================================================================
249
+ it("stuck: max rework exceeded → escalation", async () => {
250
+ const hookCtx = makeHookCtx({ pluginConfig: { maxReworkAttempts: 0 } });
251
+ const dispatch = makeDispatch(worktree);
252
+
253
+ await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
254
+
255
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
256
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
257
+ );
258
+
259
+ // Worker succeeds, audit always fails
260
+ let callCount = 0;
261
+ runAgentMock.mockImplementation(async () => {
262
+ callCount++;
263
+ if (callCount % 2 === 1) return { success: true, output: "Attempted fix.", watchdogKilled: false };
264
+ return { success: true, output: failVerdict(["still broken"]), watchdogKilled: false };
265
+ });
266
+
267
+ await spawnWorker(hookCtx, dispatch);
268
+
269
+ // maxReworkAttempts=0, so first failure should escalate (attempt 0 fails → nextAttempt=1 > max=0)
270
+ const state = await readDispatchState(hookCtx.configPath);
271
+ const stuckDispatch = state.dispatches.active["ENG-100"];
272
+ expect(stuckDispatch).toBeDefined();
273
+ expect(stuckDispatch.status).toBe("stuck");
274
+ expect(stuckDispatch.stuckReason).toContain("audit_failed");
275
+
276
+ // Escalation notification
277
+ const escalation = hookCtx.notifyCalls.find(([k]) => k === "escalation");
278
+ expect(escalation).toBeDefined();
279
+
280
+ // Escalation comment
281
+ expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
282
+ "issue-1",
283
+ expect.stringContaining("Needs Your Help"),
284
+ );
285
+ });
286
+
287
+ // =========================================================================
288
+ // Test 4: Watchdog kill
289
+ // =========================================================================
290
+ it("watchdog kill → stuck", async () => {
291
+ const hookCtx = makeHookCtx();
292
+ const dispatch = makeDispatch(worktree);
293
+
294
+ await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
295
+
296
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
297
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
298
+ );
299
+
300
+ // runAgent returns watchdog killed
301
+ runAgentMock.mockResolvedValue({
302
+ success: false,
303
+ output: "",
304
+ watchdogKilled: true,
305
+ });
306
+
307
+ await spawnWorker(hookCtx, dispatch);
308
+
309
+ // State should be stuck with watchdog reason
310
+ const state = await readDispatchState(hookCtx.configPath);
311
+ const stuckDispatch = state.dispatches.active["ENG-100"];
312
+ expect(stuckDispatch).toBeDefined();
313
+ expect(stuckDispatch.status).toBe("stuck");
314
+ expect(stuckDispatch.stuckReason).toBe("watchdog_kill_2x");
315
+
316
+ // Watchdog kill notification
317
+ const wdNotify = hookCtx.notifyCalls.find(([k]) => k === "watchdog_kill");
318
+ expect(wdNotify).toBeDefined();
319
+
320
+ // Watchdog comment
321
+ expect(hookCtx.mockLinearApi.createComment).toHaveBeenCalledWith(
322
+ "issue-1",
323
+ expect.stringContaining("Agent Timed Out"),
324
+ );
325
+ });
326
+
327
+ // =========================================================================
328
+ // Test 5: DAG cascade — pass triggers next issue
329
+ // =========================================================================
330
+ it("DAG cascade: audit pass triggers next issue dispatch", async () => {
331
+ const configDir = tmpDir();
332
+ const configPath = join(configDir, "state.json");
333
+ const hookCtx = makeHookCtx({ configPath });
334
+ const dispatch = makeDispatch(worktree, { project: "proj-1" });
335
+
336
+ await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
337
+
338
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
339
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
340
+ );
341
+
342
+ // Set up project dispatch state (ENG-100 → ENG-101)
343
+ const projectDispatch: ProjectDispatchState = {
344
+ projectId: "proj-1",
345
+ projectName: "Test Project",
346
+ rootIdentifier: "PROJ-1",
347
+ status: "dispatching",
348
+ startedAt: new Date().toISOString(),
349
+ maxConcurrent: 3,
350
+ issues: {
351
+ "ENG-100": {
352
+ identifier: "ENG-100",
353
+ issueId: "issue-1",
354
+ dependsOn: [],
355
+ unblocks: ["ENG-101"],
356
+ dispatchStatus: "dispatched",
357
+ },
358
+ "ENG-101": {
359
+ identifier: "ENG-101",
360
+ issueId: "issue-2",
361
+ dependsOn: ["ENG-100"],
362
+ unblocks: [],
363
+ dispatchStatus: "pending",
364
+ },
365
+ },
366
+ };
367
+ await writeProjectDispatch(projectDispatch, configPath);
368
+
369
+ // Worker + audit pass
370
+ let callCount = 0;
371
+ runAgentMock.mockImplementation(async () => {
372
+ callCount++;
373
+ if (callCount === 1) return { success: true, output: "Done.", watchdogKilled: false };
374
+ return { success: true, output: passVerdict(), watchdogKilled: false };
375
+ });
376
+
377
+ await spawnWorker(hookCtx, dispatch);
378
+
379
+ // Wait a tick for the async DAG cascade (void fire-and-forget)
380
+ await new Promise((r) => setTimeout(r, 100));
381
+
382
+ // Read project dispatch state — ENG-100 should be done, ENG-101 should be dispatched
383
+ const updatedProject = await readProjectDispatch("proj-1", configPath);
384
+ expect(updatedProject).not.toBeNull();
385
+ expect(updatedProject!.issues["ENG-100"].dispatchStatus).toBe("done");
386
+ expect(updatedProject!.issues["ENG-101"].dispatchStatus).toBe("dispatched");
387
+
388
+ // Verify project_progress notification
389
+ const progressNotify = hookCtx.notifyCalls.find(([k]) => k === "project_progress");
390
+ expect(progressNotify).toBeDefined();
391
+ });
392
+
393
+ // =========================================================================
394
+ // Test 6: DAG cascade — stuck propagates
395
+ // =========================================================================
396
+ it("DAG cascade: stuck propagates to project", async () => {
397
+ const configDir = tmpDir();
398
+ const configPath = join(configDir, "state.json");
399
+ const hookCtx = makeHookCtx({ configPath, pluginConfig: { maxReworkAttempts: 0 } });
400
+ const dispatch = makeDispatch(worktree, { project: "proj-1" });
401
+
402
+ await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
403
+
404
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
405
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
406
+ );
407
+
408
+ // Set up project dispatch state (ENG-100 → ENG-101, only 2 issues)
409
+ const projectDispatch: ProjectDispatchState = {
410
+ projectId: "proj-1",
411
+ projectName: "Test Project",
412
+ rootIdentifier: "PROJ-1",
413
+ status: "dispatching",
414
+ startedAt: new Date().toISOString(),
415
+ maxConcurrent: 3,
416
+ issues: {
417
+ "ENG-100": {
418
+ identifier: "ENG-100",
419
+ issueId: "issue-1",
420
+ dependsOn: [],
421
+ unblocks: ["ENG-101"],
422
+ dispatchStatus: "dispatched",
423
+ },
424
+ "ENG-101": {
425
+ identifier: "ENG-101",
426
+ issueId: "issue-2",
427
+ dependsOn: ["ENG-100"],
428
+ unblocks: [],
429
+ dispatchStatus: "pending",
430
+ },
431
+ },
432
+ };
433
+ await writeProjectDispatch(projectDispatch, configPath);
434
+
435
+ // Worker succeeds, audit fails
436
+ let callCount = 0;
437
+ runAgentMock.mockImplementation(async () => {
438
+ callCount++;
439
+ if (callCount === 1) return { success: true, output: "Attempted.", watchdogKilled: false };
440
+ return { success: true, output: failVerdict(["still broken"]), watchdogKilled: false };
441
+ });
442
+
443
+ await spawnWorker(hookCtx, dispatch);
444
+
445
+ // Wait for async DAG cascade
446
+ await new Promise((r) => setTimeout(r, 100));
447
+
448
+ // Project should be stuck since ENG-100 is stuck and ENG-101 depends on it
449
+ const updatedProject = await readProjectDispatch("proj-1", configPath);
450
+ expect(updatedProject).not.toBeNull();
451
+ expect(updatedProject!.issues["ENG-100"].dispatchStatus).toBe("stuck");
452
+ expect(updatedProject!.issues["ENG-101"].dispatchStatus).toBe("pending");
453
+ expect(updatedProject!.status).toBe("stuck");
454
+ });
455
+
456
+ // =========================================================================
457
+ // Test 7: Artifact integrity
458
+ // =========================================================================
459
+ it("artifact integrity: manifest, worker output, audit verdict, dispatch log", async () => {
460
+ const hookCtx = makeHookCtx();
461
+ const dispatch = makeDispatch(worktree);
462
+
463
+ await registerDispatch(dispatch.issueIdentifier, dispatch, hookCtx.configPath);
464
+
465
+ // Pre-create manifest (as webhook.ts handleDispatch would)
466
+ const { ensureClawDir, writeManifest } = await import("./artifacts.js");
467
+ ensureClawDir(worktree);
468
+ writeManifest(worktree, {
469
+ issueIdentifier: "ENG-100",
470
+ issueId: "issue-1",
471
+ tier: "junior",
472
+ status: "dispatched",
473
+ attempts: 0,
474
+ dispatchedAt: new Date().toISOString(),
475
+ });
476
+
477
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
478
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
479
+ );
480
+
481
+ let callCount = 0;
482
+ runAgentMock.mockImplementation(async () => {
483
+ callCount++;
484
+ if (callCount === 1) return { success: true, output: "Worker output here.", watchdogKilled: false };
485
+ return { success: true, output: passVerdict(["unit tests", "lint"]), watchdogKilled: false };
486
+ });
487
+
488
+ await spawnWorker(hookCtx, dispatch);
489
+
490
+ const clawDir = join(worktree, ".claw");
491
+
492
+ // Manifest (updated by pipeline: status→done, attempts→1)
493
+ const manifest = JSON.parse(readFileSync(join(clawDir, "manifest.json"), "utf8"));
494
+ expect(manifest.status).toBe("done");
495
+ expect(manifest.attempts).toBe(1);
496
+
497
+ // Worker output
498
+ const workerOutput = readFileSync(join(clawDir, "worker-0.md"), "utf8");
499
+ expect(workerOutput).toBe("Worker output here.");
500
+
501
+ // Audit verdict
502
+ const verdict = JSON.parse(readFileSync(join(clawDir, "audit-0.json"), "utf8"));
503
+ expect(verdict.pass).toBe(true);
504
+ expect(verdict.criteria).toContain("unit tests");
505
+
506
+ // Dispatch log (JSONL format)
507
+ const logContent = readFileSync(join(clawDir, "log.jsonl"), "utf8");
508
+ const logLines = logContent.trim().split("\n").map((l) => JSON.parse(l));
509
+ expect(logLines.some((e) => e.phase === "worker")).toBe(true);
510
+ expect(logLines.some((e) => e.phase === "audit")).toBe(true);
511
+ });
512
+
513
+ // =========================================================================
514
+ // Test 8: Multi-target notify with rich format
515
+ // =========================================================================
516
+ it("multi-target notify: discord + telegram called for lifecycle events", async () => {
517
+ // For this test, we use real createNotifierFromConfig + mock runtime channels
518
+ const { createNotifierFromConfig } = await import("../infra/notify.js");
519
+
520
+ const mockRuntime = {
521
+ channel: {
522
+ discord: { sendMessageDiscord: vi.fn().mockResolvedValue(undefined) },
523
+ telegram: { sendMessageTelegram: vi.fn().mockResolvedValue(undefined) },
524
+ slack: { sendMessageSlack: vi.fn().mockResolvedValue(undefined) },
525
+ signal: { sendMessageSignal: vi.fn().mockResolvedValue(undefined) },
526
+ },
527
+ };
528
+
529
+ const pluginConfig = {
530
+ notifications: {
531
+ targets: [
532
+ { channel: "discord", target: "discord-channel-1" },
533
+ { channel: "telegram", target: "telegram-chat-1" },
534
+ ],
535
+ richFormat: true,
536
+ },
537
+ };
538
+
539
+ const notify = createNotifierFromConfig(pluginConfig, mockRuntime as any);
540
+
541
+ const configDir = tmpDir();
542
+ const configPath = join(configDir, "state.json");
543
+ const hookCtx = makeHookCtx({
544
+ configPath,
545
+ pluginConfig,
546
+ });
547
+ // Override notify with the real notifier
548
+ (hookCtx as any).notify = notify;
549
+
550
+ const dispatch = makeDispatch(worktree);
551
+ await registerDispatch(dispatch.issueIdentifier, dispatch, configPath);
552
+
553
+ hookCtx.mockLinearApi.getIssueDetails.mockResolvedValue(
554
+ makeIssueDetails({ id: "issue-1", identifier: "ENG-100", title: "Fix auth" }),
555
+ );
556
+
557
+ let callCount = 0;
558
+ runAgentMock.mockImplementation(async () => {
559
+ callCount++;
560
+ if (callCount === 1) return { success: true, output: "Done.", watchdogKilled: false };
561
+ return { success: true, output: passVerdict(), watchdogKilled: false };
562
+ });
563
+
564
+ await spawnWorker(hookCtx, dispatch);
565
+
566
+ // Both channels should have been called for "working", "auditing", "audit_pass"
567
+ const discordCalls = mockRuntime.channel.discord.sendMessageDiscord.mock.calls;
568
+ const telegramCalls = mockRuntime.channel.telegram.sendMessageTelegram.mock.calls;
569
+
570
+ // At least 3 events (working, auditing, audit_pass) × both channels
571
+ expect(discordCalls.length).toBeGreaterThanOrEqual(3);
572
+ expect(telegramCalls.length).toBeGreaterThanOrEqual(3);
573
+
574
+ // Verify Discord got the right target
575
+ for (const call of discordCalls) {
576
+ expect(call[0]).toBe("discord-channel-1");
577
+ }
578
+
579
+ // Verify Telegram got the right target with HTML (rich format)
580
+ for (const call of telegramCalls) {
581
+ expect(call[0]).toBe("telegram-chat-1");
582
+ }
583
+ });
584
+ });