@calltelemetry/openclaw-linear 0.8.2 → 0.8.4

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,631 @@
1
+ /**
2
+ * webhook-scenarios.test.ts — Full handler flow tests using captured payloads.
3
+ *
4
+ * Replays webhook payloads through handleLinearWebhook with mocked API
5
+ * dependencies. Tests the complete async handler behavior for each event
6
+ * type: API calls made, session lifecycle, agent runs, and responses.
7
+ *
8
+ * Unlike webhook-dedup.test.ts (dedup logic) and webhook.test.ts (HTTP basics),
9
+ * these tests verify the full business logic paths end-to-end.
10
+ *
11
+ * Key pattern: handlers prefer emitActivity(response) over createComment
12
+ * when an agent session exists — createComment is only used as a fallback
13
+ * when the session activity emission fails.
14
+ */
15
+ import type { AddressInfo } from "node:net";
16
+ import { createServer } from "node:http";
17
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+
20
+ // ── Hoisted mock references ──────────────────────────────────────
21
+
22
+ const {
23
+ mockRunAgent,
24
+ mockGetViewerId,
25
+ mockGetIssueDetails,
26
+ mockCreateComment,
27
+ mockEmitActivity,
28
+ mockUpdateSession,
29
+ mockUpdateIssue,
30
+ mockGetTeamLabels,
31
+ mockGetTeamStates,
32
+ mockCreateSessionOnIssue,
33
+ mockClassifyIntent,
34
+ mockSpawnWorker,
35
+ mockSetActiveSession,
36
+ mockClearActiveSession,
37
+ mockEmitDiagnostic,
38
+ } = vi.hoisted(() => ({
39
+ mockRunAgent: vi.fn(),
40
+ mockGetViewerId: vi.fn(),
41
+ mockGetIssueDetails: vi.fn(),
42
+ mockCreateComment: vi.fn(),
43
+ mockEmitActivity: vi.fn(),
44
+ mockUpdateSession: vi.fn(),
45
+ mockUpdateIssue: vi.fn(),
46
+ mockGetTeamLabels: vi.fn(),
47
+ mockGetTeamStates: vi.fn(),
48
+ mockCreateSessionOnIssue: vi.fn(),
49
+ mockClassifyIntent: vi.fn(),
50
+ mockSpawnWorker: vi.fn(),
51
+ mockSetActiveSession: vi.fn(),
52
+ mockClearActiveSession: vi.fn(),
53
+ mockEmitDiagnostic: vi.fn(),
54
+ }));
55
+
56
+ // ── Module mocks (must precede all imports of tested code) ───────
57
+
58
+ vi.mock("../agent/agent.js", () => ({
59
+ runAgent: mockRunAgent,
60
+ }));
61
+
62
+ vi.mock("../api/linear-api.js", () => ({
63
+ LinearAgentApi: class MockLinearAgentApi {
64
+ emitActivity = mockEmitActivity;
65
+ createComment = mockCreateComment;
66
+ getIssueDetails = mockGetIssueDetails;
67
+ updateSession = mockUpdateSession;
68
+ getViewerId = mockGetViewerId;
69
+ updateIssue = mockUpdateIssue;
70
+ getTeamLabels = mockGetTeamLabels;
71
+ getTeamStates = mockGetTeamStates;
72
+ createSessionOnIssue = mockCreateSessionOnIssue;
73
+ },
74
+ resolveLinearToken: vi.fn().mockReturnValue({
75
+ accessToken: "test-token",
76
+ source: "env",
77
+ }),
78
+ }));
79
+
80
+ vi.mock("../pipeline/pipeline.js", () => ({
81
+ spawnWorker: mockSpawnWorker,
82
+ runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
83
+ runFullPipeline: vi.fn().mockResolvedValue(undefined),
84
+ resumePipeline: vi.fn().mockResolvedValue(undefined),
85
+ }));
86
+
87
+ vi.mock("../pipeline/active-session.js", () => ({
88
+ setActiveSession: mockSetActiveSession,
89
+ clearActiveSession: mockClearActiveSession,
90
+ }));
91
+
92
+ vi.mock("../infra/observability.js", () => ({
93
+ emitDiagnostic: mockEmitDiagnostic,
94
+ }));
95
+
96
+ vi.mock("../pipeline/intent-classify.js", () => ({
97
+ classifyIntent: mockClassifyIntent,
98
+ }));
99
+
100
+ vi.mock("../pipeline/dispatch-state.js", () => ({
101
+ readDispatchState: vi.fn().mockResolvedValue({ dispatches: { active: {}, completed: {} }, sessionMap: {} }),
102
+ getActiveDispatch: vi.fn().mockReturnValue(null),
103
+ registerDispatch: vi.fn().mockResolvedValue(undefined),
104
+ updateDispatchStatus: vi.fn().mockResolvedValue(undefined),
105
+ completeDispatch: vi.fn().mockResolvedValue(undefined),
106
+ removeActiveDispatch: vi.fn().mockResolvedValue(undefined),
107
+ }));
108
+
109
+ vi.mock("../infra/notify.js", () => ({
110
+ createNotifierFromConfig: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
111
+ }));
112
+
113
+ vi.mock("../pipeline/tier-assess.js", () => ({
114
+ assessTier: vi.fn().mockResolvedValue({ tier: "standard", model: "test-model", reasoning: "mock assessment" }),
115
+ }));
116
+
117
+ vi.mock("../infra/codex-worktree.js", () => ({
118
+ createWorktree: vi.fn().mockReturnValue({ path: "/tmp/mock-worktree", branch: "codex/ENG-123", resumed: false }),
119
+ createMultiWorktree: vi.fn(),
120
+ prepareWorkspace: vi.fn().mockReturnValue({ pulled: false, submodulesInitialized: false, errors: [] }),
121
+ }));
122
+
123
+ vi.mock("../infra/multi-repo.js", () => ({
124
+ resolveRepos: vi.fn().mockReturnValue({ repos: [] }),
125
+ isMultiRepo: vi.fn().mockReturnValue(false),
126
+ }));
127
+
128
+ vi.mock("../pipeline/artifacts.js", () => ({
129
+ ensureClawDir: vi.fn(),
130
+ writeManifest: vi.fn(),
131
+ writeDispatchMemory: vi.fn(),
132
+ resolveOrchestratorWorkspace: vi.fn().mockReturnValue("/mock/workspace"),
133
+ }));
134
+
135
+ vi.mock("../pipeline/planning-state.js", () => ({
136
+ readPlanningState: vi.fn().mockResolvedValue({ sessions: {} }),
137
+ isInPlanningMode: vi.fn().mockReturnValue(false),
138
+ getPlanningSession: vi.fn().mockReturnValue(null),
139
+ endPlanningSession: vi.fn().mockResolvedValue(undefined),
140
+ }));
141
+
142
+ vi.mock("../pipeline/planner.js", () => ({
143
+ initiatePlanningSession: vi.fn().mockResolvedValue(undefined),
144
+ handlePlannerTurn: vi.fn().mockResolvedValue(undefined),
145
+ runPlanAudit: vi.fn().mockResolvedValue(undefined),
146
+ }));
147
+
148
+ vi.mock("../pipeline/dag-dispatch.js", () => ({
149
+ startProjectDispatch: vi.fn().mockResolvedValue(undefined),
150
+ }));
151
+
152
+ // ── Imports (after mocks) ────────────────────────────────────────
153
+
154
+ import { handleLinearWebhook, _resetForTesting } from "../pipeline/webhook.js";
155
+ import {
156
+ makeAgentSessionEventCreated,
157
+ makeAgentSessionEventPrompted,
158
+ makeCommentCreate,
159
+ makeCommentCreateFromBot,
160
+ makeIssueCreate,
161
+ makeIssueUpdateWithAssignment,
162
+ makeAppUserNotification,
163
+ } from "./fixtures/webhook-payloads.js";
164
+ import { makeIssueDetails } from "./fixtures/linear-responses.js";
165
+
166
+ // ── Helpers ──────────────────────────────────────────────────────
167
+
168
+ function createApi(): OpenClawPluginApi {
169
+ return {
170
+ logger: {
171
+ info: vi.fn(),
172
+ warn: vi.fn(),
173
+ error: vi.fn(),
174
+ debug: vi.fn(),
175
+ },
176
+ runtime: {},
177
+ pluginConfig: { defaultAgentId: "mal" },
178
+ } as unknown as OpenClawPluginApi;
179
+ }
180
+
181
+ async function withServer(
182
+ handler: Parameters<typeof createServer>[0],
183
+ fn: (baseUrl: string) => Promise<void>,
184
+ ) {
185
+ const server = createServer(handler);
186
+ await new Promise<void>((resolve) => {
187
+ server.listen(0, "127.0.0.1", () => resolve());
188
+ });
189
+ const address = server.address() as AddressInfo | null;
190
+ if (!address) throw new Error("missing server address");
191
+ try {
192
+ await fn(`http://127.0.0.1:${address.port}`);
193
+ } finally {
194
+ await new Promise<void>((resolve) => server.close(() => resolve()));
195
+ }
196
+ }
197
+
198
+ async function postWebhook(api: OpenClawPluginApi, payload: unknown) {
199
+ let status = 0;
200
+ let body = "";
201
+ await withServer(
202
+ async (req, res) => {
203
+ await handleLinearWebhook(api, req, res);
204
+ },
205
+ async (baseUrl) => {
206
+ const response = await fetch(`${baseUrl}/linear/webhook`, {
207
+ method: "POST",
208
+ headers: { "content-type": "application/json" },
209
+ body: JSON.stringify(payload),
210
+ });
211
+ status = response.status;
212
+ body = await response.text();
213
+ },
214
+ );
215
+ return { status, body };
216
+ }
217
+
218
+ function infoLogs(api: OpenClawPluginApi): string[] {
219
+ return (api.logger.info as ReturnType<typeof vi.fn>).mock.calls.map(
220
+ (c: unknown[]) => String(c[0]),
221
+ );
222
+ }
223
+
224
+ function errorLogs(api: OpenClawPluginApi): string[] {
225
+ return (api.logger.error as ReturnType<typeof vi.fn>).mock.calls.map(
226
+ (c: unknown[]) => String(c[0]),
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Wait for a mock to be called within a timeout.
232
+ * Used for fire-and-forget `void (async () => {...})()` handlers.
233
+ */
234
+ async function waitForMock(
235
+ mock: ReturnType<typeof vi.fn>,
236
+ opts?: { timeout?: number; times?: number },
237
+ ): Promise<void> {
238
+ const timeout = opts?.timeout ?? 2000;
239
+ const times = opts?.times ?? 1;
240
+ await vi.waitFor(
241
+ () => { expect(mock).toHaveBeenCalledTimes(times); },
242
+ { timeout, interval: 50 },
243
+ );
244
+ }
245
+
246
+ /** Extract emitActivity calls that have a specific type (thought, response, error, action). */
247
+ function activityCallsOfType(type: string): unknown[][] {
248
+ return mockEmitActivity.mock.calls.filter(
249
+ (c: unknown[]) => (c[1] as any)?.type === type,
250
+ );
251
+ }
252
+
253
+ // ── Setup / Teardown ─────────────────────────────────────────────
254
+
255
+ beforeEach(() => {
256
+ vi.clearAllMocks();
257
+ _resetForTesting();
258
+
259
+ // Default mock behaviors
260
+ mockGetViewerId.mockResolvedValue("viewer-bot-1");
261
+ mockGetIssueDetails.mockResolvedValue(makeIssueDetails());
262
+ mockCreateComment.mockResolvedValue("comment-new-id");
263
+ mockEmitActivity.mockResolvedValue(undefined);
264
+ mockUpdateSession.mockResolvedValue(undefined);
265
+ mockUpdateIssue.mockResolvedValue(true);
266
+ mockCreateSessionOnIssue.mockResolvedValue({ sessionId: "session-mock-1" });
267
+ mockGetTeamLabels.mockResolvedValue([
268
+ { id: "label-bug", name: "Bug" },
269
+ { id: "label-feature", name: "Feature" },
270
+ ]);
271
+ mockGetTeamStates.mockResolvedValue([
272
+ { id: "st-backlog", name: "Backlog", type: "backlog" },
273
+ { id: "st-started", name: "In Progress", type: "started" },
274
+ { id: "st-done", name: "Done", type: "completed" },
275
+ { id: "st-canceled", name: "Canceled", type: "canceled" },
276
+ ]);
277
+ mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
278
+ mockSpawnWorker.mockResolvedValue(undefined);
279
+ mockClassifyIntent.mockResolvedValue({
280
+ intent: "general",
281
+ reasoning: "test fallback",
282
+ fromFallback: true,
283
+ });
284
+ });
285
+
286
+ afterEach(() => {
287
+ _resetForTesting();
288
+ });
289
+
290
+ // ── Tests ────────────────────────────────────────────────────────
291
+
292
+ describe("webhook scenario tests — full handler flows", () => {
293
+ describe("AgentSessionEvent", () => {
294
+ it("created: runs agent, delivers response via emitActivity", async () => {
295
+ const api = createApi();
296
+ const payload = makeAgentSessionEventCreated();
297
+ const result = await postWebhook(api, payload);
298
+ expect(result.status).toBe(200);
299
+
300
+ // Wait for the fire-and-forget handler to complete
301
+ await waitForMock(mockClearActiveSession);
302
+
303
+ // Issue enrichment
304
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-1");
305
+
306
+ // Agent invoked with correct session/message
307
+ expect(mockRunAgent).toHaveBeenCalledOnce();
308
+ const runArgs = mockRunAgent.mock.calls[0][0];
309
+ expect(runArgs.sessionId).toContain("linear-session-sess-event-1");
310
+ expect(runArgs.message).toContain("ENG-123");
311
+
312
+ // emitActivity called with thought and response
313
+ expect(activityCallsOfType("thought").length).toBeGreaterThan(0);
314
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
315
+
316
+ // Response delivered via emitActivity (session-first pattern),
317
+ // NOT via createComment — avoids duplicate visible messages.
318
+ expect(mockCreateComment).not.toHaveBeenCalled();
319
+
320
+ // Session lifecycle
321
+ expect(mockSetActiveSession).toHaveBeenCalledWith(
322
+ expect.objectContaining({ issueId: "issue-1", agentSessionId: "sess-event-1" }),
323
+ );
324
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
325
+ });
326
+
327
+ it("created: falls back to createComment when emitActivity fails", async () => {
328
+ // Make the response emitActivity fail — comment is the fallback
329
+ let emitCallCount = 0;
330
+ mockEmitActivity.mockImplementation(async (_sessionId: string, content: any) => {
331
+ emitCallCount++;
332
+ // Let the "thought" emission succeed, but fail the "response" emission
333
+ if (content?.type === "response") {
334
+ throw new Error("session expired");
335
+ }
336
+ });
337
+
338
+ const api = createApi();
339
+ const payload = makeAgentSessionEventCreated();
340
+ await postWebhook(api, payload);
341
+
342
+ await waitForMock(mockClearActiveSession);
343
+
344
+ // runAgent was called
345
+ expect(mockRunAgent).toHaveBeenCalledOnce();
346
+
347
+ // emitActivity(response) failed → fell back to createComment
348
+ expect(mockCreateComment).toHaveBeenCalledOnce();
349
+ const commentBody = mockCreateComment.mock.calls[0][1] as string;
350
+ expect(commentBody).toContain("Agent response text");
351
+ });
352
+
353
+ it("prompted: processes follow-up, delivers via emitActivity", async () => {
354
+ const api = createApi();
355
+ const payload = makeAgentSessionEventPrompted();
356
+ const result = await postWebhook(api, payload);
357
+ expect(result.status).toBe(200);
358
+
359
+ await waitForMock(mockClearActiveSession);
360
+
361
+ expect(mockRunAgent).toHaveBeenCalledOnce();
362
+ const msg = mockRunAgent.mock.calls[0][0].message;
363
+ expect(msg).toContain("Follow-up question here");
364
+
365
+ // Response via emitActivity, not createComment
366
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
367
+ expect(mockCreateComment).not.toHaveBeenCalled();
368
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
369
+ });
370
+
371
+ it("created with missing data: logs error, no crash", async () => {
372
+ const api = createApi();
373
+ const payload = {
374
+ type: "AgentSessionEvent",
375
+ action: "created",
376
+ agentSession: { id: null, issue: null },
377
+ previousComments: [],
378
+ };
379
+ const result = await postWebhook(api, payload);
380
+ expect(result.status).toBe(200);
381
+
382
+ const errors = errorLogs(api);
383
+ expect(errors.some((e) => e.includes("missing session or issue"))).toBe(true);
384
+ expect(mockRunAgent).not.toHaveBeenCalled();
385
+ });
386
+ });
387
+
388
+ describe("Comment.create", () => {
389
+ it("ask_agent intent: dispatches to named agent", async () => {
390
+ mockClassifyIntent.mockResolvedValue({
391
+ intent: "ask_agent",
392
+ agentId: "mal",
393
+ reasoning: "user asking for work",
394
+ fromFallback: false,
395
+ });
396
+
397
+ const api = createApi();
398
+ const payload = makeCommentCreate({
399
+ data: {
400
+ id: "comment-intent-1",
401
+ body: "Can someone look at this issue?",
402
+ user: { id: "user-human", name: "Human" },
403
+ issue: {
404
+ id: "issue-intent-1",
405
+ identifier: "ENG-301",
406
+ title: "Intent test",
407
+ team: { id: "team-1" },
408
+ project: null,
409
+ },
410
+ createdAt: new Date().toISOString(),
411
+ },
412
+ });
413
+ await postWebhook(api, payload);
414
+
415
+ // Wait for the full dispatch to complete
416
+ await waitForMock(mockClearActiveSession);
417
+
418
+ expect(mockClassifyIntent).toHaveBeenCalledOnce();
419
+ expect(mockRunAgent).toHaveBeenCalledOnce();
420
+
421
+ // Session created → response via emitActivity
422
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
423
+ });
424
+
425
+ it("request_work intent: dispatches to default agent", async () => {
426
+ mockClassifyIntent.mockResolvedValue({
427
+ intent: "request_work",
428
+ reasoning: "user wants implementation",
429
+ fromFallback: false,
430
+ });
431
+
432
+ const api = createApi();
433
+ const payload = makeCommentCreate({
434
+ data: {
435
+ id: "comment-work-1",
436
+ body: "Please implement the login page",
437
+ user: { id: "user-human", name: "Human" },
438
+ issue: {
439
+ id: "issue-work-1",
440
+ identifier: "ENG-350",
441
+ title: "Login implementation",
442
+ team: { id: "team-1" },
443
+ project: null,
444
+ },
445
+ createdAt: new Date().toISOString(),
446
+ },
447
+ });
448
+ await postWebhook(api, payload);
449
+
450
+ await waitForMock(mockRunAgent);
451
+
452
+ expect(mockClassifyIntent).toHaveBeenCalledOnce();
453
+
454
+ const logs = infoLogs(api);
455
+ expect(logs.some((l) => l.includes("request_work"))).toBe(true);
456
+ expect(mockRunAgent).toHaveBeenCalledOnce();
457
+ });
458
+
459
+ it("bot's own comment: skips without running agent", async () => {
460
+ const api = createApi();
461
+ const payload = makeCommentCreateFromBot("viewer-bot-1");
462
+ await postWebhook(api, payload);
463
+ // Small wait for the async getViewerId check
464
+ await new Promise((r) => setTimeout(r, 100));
465
+
466
+ const logs = infoLogs(api);
467
+ expect(logs.some((l) => l.includes("skipping our own comment"))).toBe(true);
468
+ expect(mockRunAgent).not.toHaveBeenCalled();
469
+ expect(mockClassifyIntent).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it("general intent: no action taken, no agent run", async () => {
473
+ const api = createApi();
474
+ const payload = makeCommentCreate({
475
+ data: {
476
+ id: "comment-general-1",
477
+ body: "Thanks for the update",
478
+ user: { id: "user-human", name: "Human" },
479
+ issue: {
480
+ id: "issue-general-1",
481
+ identifier: "ENG-302",
482
+ title: "General test",
483
+ team: { id: "team-1" },
484
+ project: null,
485
+ },
486
+ createdAt: new Date().toISOString(),
487
+ },
488
+ });
489
+ await postWebhook(api, payload);
490
+ await vi.waitFor(
491
+ () => { expect(mockClassifyIntent).toHaveBeenCalledOnce(); },
492
+ { timeout: 2000, interval: 50 },
493
+ );
494
+
495
+ const logs = infoLogs(api);
496
+ expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
497
+ expect(mockRunAgent).not.toHaveBeenCalled();
498
+ });
499
+
500
+ it("close_issue intent: generates closure report, transitions state, posts comment", async () => {
501
+ mockClassifyIntent.mockResolvedValue({
502
+ intent: "close_issue",
503
+ reasoning: "user wants to close the issue",
504
+ fromFallback: false,
505
+ });
506
+
507
+ mockRunAgent.mockResolvedValueOnce({
508
+ success: true,
509
+ output: "**Summary**: Fixed the authentication bug.\n**Resolution**: Updated token refresh logic.",
510
+ });
511
+
512
+ const api = createApi();
513
+ const payload = makeCommentCreate({
514
+ data: {
515
+ id: "comment-close-1",
516
+ body: "close this issue",
517
+ user: { id: "user-human", name: "Human" },
518
+ issue: {
519
+ id: "issue-close-1",
520
+ identifier: "ENG-400",
521
+ title: "Auth bug fix",
522
+ team: { id: "team-1" },
523
+ project: null,
524
+ },
525
+ createdAt: new Date().toISOString(),
526
+ },
527
+ });
528
+ await postWebhook(api, payload);
529
+
530
+ await waitForMock(mockClearActiveSession);
531
+
532
+ // Agent ran with readOnly for closure report
533
+ expect(mockRunAgent).toHaveBeenCalledOnce();
534
+ const runArgs = mockRunAgent.mock.calls[0][0];
535
+ expect(runArgs.readOnly).toBe(true);
536
+ expect(runArgs.message).toContain("closure report");
537
+
538
+ // Issue state transitioned to completed
539
+ expect(mockUpdateIssue).toHaveBeenCalledWith("issue-close-1", { stateId: "st-done" });
540
+
541
+ // Team states fetched to find completed state
542
+ expect(mockGetTeamStates).toHaveBeenCalledWith("team-1");
543
+
544
+ // Closure report posted via emitActivity
545
+ const responseCalls = activityCallsOfType("response");
546
+ expect(responseCalls.length).toBeGreaterThan(0);
547
+ const reportBody = (responseCalls[0][1] as any).body;
548
+ expect(reportBody).toContain("Closure Report");
549
+ expect(reportBody).toContain("authentication bug");
550
+ });
551
+ });
552
+
553
+ describe("Issue.update", () => {
554
+ it("assignment dispatch: triggers handleDispatch pipeline", async () => {
555
+ // Set viewerId to match the fixture's assigneeId
556
+ mockGetViewerId.mockResolvedValue("viewer-1");
557
+
558
+ const api = createApi();
559
+ const payload = makeIssueUpdateWithAssignment();
560
+ await postWebhook(api, payload);
561
+
562
+ await waitForMock(mockSpawnWorker, { timeout: 3000 });
563
+ expect(mockSpawnWorker).toHaveBeenCalledOnce();
564
+ });
565
+ });
566
+
567
+ describe("Issue.create", () => {
568
+ it("auto-triage: applies estimate, labels, priority from agent output", async () => {
569
+ // Mock getIssueDetails to return issue matching the payload
570
+ mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
571
+ id: "issue-new",
572
+ identifier: "ENG-200",
573
+ title: "New issue",
574
+ }));
575
+
576
+ mockRunAgent.mockResolvedValueOnce({
577
+ success: true,
578
+ output:
579
+ '```json\n{"estimate": 3, "labelIds": ["label-bug"], "priority": 3, "assessment": "Medium complexity"}\n```\n\nThis issue needs moderate work.',
580
+ });
581
+
582
+ const api = createApi();
583
+ const payload = makeIssueCreate();
584
+ await postWebhook(api, payload);
585
+
586
+ // Wait for the triage handler to complete
587
+ await waitForMock(mockClearActiveSession);
588
+
589
+ // Issue enrichment + team labels
590
+ expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-new");
591
+ expect(mockGetTeamLabels).toHaveBeenCalled();
592
+
593
+ // Session created for triage
594
+ expect(mockCreateSessionOnIssue).toHaveBeenCalledWith("issue-new");
595
+
596
+ // Agent invoked in read-only mode
597
+ expect(mockRunAgent).toHaveBeenCalledOnce();
598
+ const runArgs = mockRunAgent.mock.calls[0][0];
599
+ expect(runArgs.readOnly).toBe(true);
600
+ expect(runArgs.message).toContain("ENG-200");
601
+
602
+ // Triage JSON applied to issue
603
+ expect(mockUpdateIssue).toHaveBeenCalledWith(
604
+ "issue-new",
605
+ expect.objectContaining({
606
+ estimate: 3,
607
+ priority: 3,
608
+ }),
609
+ );
610
+
611
+ // Response delivered via emitActivity (session exists)
612
+ expect(activityCallsOfType("response").length).toBeGreaterThan(0);
613
+ expect(mockClearActiveSession).toHaveBeenCalledWith("issue-new");
614
+ });
615
+ });
616
+
617
+ describe("AppUserNotification", () => {
618
+ it("ignored: returns 200 with no API calls", async () => {
619
+ const api = createApi();
620
+ const result = await postWebhook(api, makeAppUserNotification());
621
+ expect(result.status).toBe(200);
622
+
623
+ const logs = infoLogs(api);
624
+ expect(logs.some((l) => l.includes("AppUserNotification ignored"))).toBe(true);
625
+
626
+ expect(mockRunAgent).not.toHaveBeenCalled();
627
+ expect(mockCreateComment).not.toHaveBeenCalled();
628
+ expect(mockGetIssueDetails).not.toHaveBeenCalled();
629
+ });
630
+ });
631
+ });