@calltelemetry/openclaw-linear 0.9.4 → 0.9.6

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,388 @@
1
+ /**
2
+ * sub-issue-decomposition.test.ts — Mock replay of sub-issue creation flow.
3
+ *
4
+ * Uses recorded API responses from the smoke test to verify parent-child
5
+ * hierarchy creation, parentId resolution, and issue relation handling.
6
+ *
7
+ * Run with: npx vitest run src/pipeline/sub-issue-decomposition.test.ts
8
+ * No credentials required — all API calls use recorded fixtures.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+
12
+ // Mock external dependencies before imports
13
+ vi.mock("openclaw/plugin-sdk", () => ({
14
+ jsonResult: (data: any) => ({ type: "json", data }),
15
+ }));
16
+
17
+ vi.mock("../api/linear-api.js", () => ({
18
+ LinearAgentApi: vi.fn(),
19
+ }));
20
+
21
+ import { RECORDED } from "../__test__/fixtures/recorded-sub-issue-flow.js";
22
+ import {
23
+ createPlannerTools,
24
+ setActivePlannerContext,
25
+ clearActivePlannerContext,
26
+ detectCycles,
27
+ auditPlan,
28
+ buildPlanSnapshot,
29
+ } from "../tools/planner-tools.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type ProjectIssue = Parameters<typeof detectCycles>[0][number];
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function createReplayApi() {
42
+ const api = {
43
+ getTeamStates: vi.fn().mockResolvedValue(RECORDED.teamStates),
44
+ createIssue: vi.fn(),
45
+ getIssueDetails: vi.fn(),
46
+ createIssueRelation: vi.fn().mockResolvedValue(RECORDED.createRelation),
47
+ getProjectIssues: vi.fn(),
48
+ getTeamLabels: vi.fn().mockResolvedValue([]),
49
+ updateIssue: vi.fn().mockResolvedValue(true),
50
+ updateIssueExtended: vi.fn().mockResolvedValue(true),
51
+ getViewerId: vi.fn().mockResolvedValue("viewer-1"),
52
+ createComment: vi.fn().mockResolvedValue("comment-id"),
53
+ emitActivity: vi.fn().mockResolvedValue(undefined),
54
+ updateSession: vi.fn().mockResolvedValue(undefined),
55
+ getProject: vi.fn().mockResolvedValue({
56
+ id: "proj-1",
57
+ name: "Test",
58
+ description: "",
59
+ state: "started",
60
+ teams: {
61
+ nodes: [
62
+ {
63
+ id: RECORDED.parentDetails.team.id,
64
+ name: RECORDED.parentDetails.team.name,
65
+ },
66
+ ],
67
+ },
68
+ }),
69
+ };
70
+
71
+ // Wire up getIssueDetails to return recorded response by ID
72
+ api.getIssueDetails.mockImplementation((id: string) => {
73
+ if (id === RECORDED.createParent.id)
74
+ return Promise.resolve(RECORDED.parentDetails);
75
+ if (id === RECORDED.createSubIssue1.id)
76
+ return Promise.resolve(RECORDED.subIssue1WithRelation);
77
+ if (id === RECORDED.createSubIssue2.id)
78
+ return Promise.resolve(RECORDED.subIssue2WithRelation);
79
+ throw new Error(`Unexpected issue ID in replay: ${id}`);
80
+ });
81
+
82
+ return api;
83
+ }
84
+
85
+ /** Build a ProjectIssue from recorded detail shapes. */
86
+ function recordedToProjectIssue(
87
+ detail: typeof RECORDED.parentDetails,
88
+ overrides?: Partial<ProjectIssue>,
89
+ ): ProjectIssue {
90
+ return {
91
+ id: detail.id,
92
+ identifier: detail.identifier,
93
+ title: detail.title,
94
+ description: detail.description,
95
+ estimate: detail.estimate,
96
+ priority: 0,
97
+ state: detail.state,
98
+ parent: detail.parent,
99
+ labels: detail.labels,
100
+ relations: detail.relations,
101
+ ...overrides,
102
+ } as ProjectIssue;
103
+ }
104
+
105
+ // ===========================================================================
106
+ // Group A: Direct API hierarchy (mock createIssue / getIssueDetails)
107
+ // ===========================================================================
108
+
109
+ describe("sub-issue decomposition (recorded replay)", () => {
110
+ describe("parent-child hierarchy via direct API", () => {
111
+ it("createIssue with parentId creates a sub-issue", async () => {
112
+ const api = createReplayApi();
113
+ api.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
114
+
115
+ const result = await api.createIssue({
116
+ teamId: RECORDED.parentDetails.team.id,
117
+ title: RECORDED.subIssue1Details.title,
118
+ parentId: RECORDED.createParent.id,
119
+ estimate: 2,
120
+ priority: 3,
121
+ });
122
+
123
+ expect(result.id).toBe(RECORDED.createSubIssue1.id);
124
+ expect(result.identifier).toBe(RECORDED.createSubIssue1.identifier);
125
+ expect(api.createIssue).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ parentId: RECORDED.createParent.id,
128
+ }),
129
+ );
130
+ });
131
+
132
+ it("getIssueDetails of sub-issue returns parent reference", async () => {
133
+ const api = createReplayApi();
134
+ const details = await api.getIssueDetails(
135
+ RECORDED.createSubIssue1.id,
136
+ );
137
+
138
+ expect(details.parent).not.toBeNull();
139
+ expect(details.parent!.id).toBe(RECORDED.createParent.id);
140
+ expect(details.parent!.identifier).toBe(
141
+ RECORDED.createParent.identifier,
142
+ );
143
+ });
144
+
145
+ it("getIssueDetails of parent returns null parent (root)", async () => {
146
+ const api = createReplayApi();
147
+ const details = await api.getIssueDetails(RECORDED.createParent.id);
148
+
149
+ expect(details.parent).toBeNull();
150
+ });
151
+
152
+ it("createIssueRelation creates blocks dependency", async () => {
153
+ const api = createReplayApi();
154
+ const result = await api.createIssueRelation({
155
+ issueId: RECORDED.createSubIssue1.id,
156
+ relatedIssueId: RECORDED.createSubIssue2.id,
157
+ type: "blocks",
158
+ });
159
+
160
+ expect(result.id).toBe(RECORDED.createRelation.id);
161
+ expect(api.createIssueRelation).toHaveBeenCalledWith({
162
+ issueId: RECORDED.createSubIssue1.id,
163
+ relatedIssueId: RECORDED.createSubIssue2.id,
164
+ type: "blocks",
165
+ });
166
+ });
167
+
168
+ it("sub-issue details include blocks relation after linking", async () => {
169
+ const api = createReplayApi();
170
+ const details = await api.getIssueDetails(
171
+ RECORDED.createSubIssue1.id,
172
+ );
173
+
174
+ const blocksRels = details.relations.nodes.filter(
175
+ (r: any) => r.type === "blocks",
176
+ );
177
+ expect(blocksRels.length).toBeGreaterThan(0);
178
+ expect(
179
+ blocksRels.some(
180
+ (r: any) => r.relatedIssue.id === RECORDED.createSubIssue2.id,
181
+ ),
182
+ ).toBe(true);
183
+ });
184
+ });
185
+
186
+ // =========================================================================
187
+ // Group B: Planner tools (real tool code, mocked API)
188
+ // =========================================================================
189
+
190
+ describe("planner tools: parentIdentifier resolution", () => {
191
+ let tools: any[];
192
+ let mockApi: ReturnType<typeof createReplayApi>;
193
+
194
+ beforeEach(() => {
195
+ vi.clearAllMocks();
196
+ mockApi = createReplayApi();
197
+ setActivePlannerContext({
198
+ linearApi: mockApi as any,
199
+ projectId: "proj-1",
200
+ teamId: RECORDED.parentDetails.team.id,
201
+ api: { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } } as any,
202
+ });
203
+ tools = createPlannerTools();
204
+ });
205
+
206
+ afterEach(() => {
207
+ clearActivePlannerContext();
208
+ });
209
+
210
+ function findTool(name: string) {
211
+ const tool = tools.find((t: any) => t.name === name) as any;
212
+ if (!tool) throw new Error(`Tool '${name}' not found`);
213
+ return tool;
214
+ }
215
+
216
+ it("plan_create_issue resolves parentIdentifier to parentId", async () => {
217
+ // Mock getProjectIssues to return the parent issue
218
+ mockApi.getProjectIssues.mockResolvedValueOnce([
219
+ recordedToProjectIssue(RECORDED.parentDetails),
220
+ ]);
221
+ mockApi.createIssue.mockResolvedValueOnce(RECORDED.createSubIssue1);
222
+
223
+ const tool = findTool("plan_create_issue");
224
+ const result = await tool.execute("call-1", {
225
+ title: RECORDED.subIssue1Details.title,
226
+ description: RECORDED.subIssue1Details.description,
227
+ parentIdentifier: RECORDED.createParent.identifier,
228
+ estimate: 2,
229
+ priority: 3,
230
+ });
231
+
232
+ // Verify createIssue was called with resolved parentId (not identifier)
233
+ expect(mockApi.createIssue).toHaveBeenCalledWith(
234
+ expect.objectContaining({
235
+ parentId: RECORDED.createParent.id,
236
+ }),
237
+ );
238
+ expect(result.data.identifier).toBe(
239
+ RECORDED.createSubIssue1.identifier,
240
+ );
241
+ });
242
+
243
+ it("plan_link_issues creates blocks relation between resolved IDs", async () => {
244
+ // Mock getProjectIssues to return both sub-issues
245
+ mockApi.getProjectIssues.mockResolvedValueOnce([
246
+ recordedToProjectIssue(RECORDED.subIssue1WithRelation),
247
+ recordedToProjectIssue(RECORDED.subIssue2WithRelation),
248
+ ]);
249
+
250
+ const tool = findTool("plan_link_issues");
251
+ const result = await tool.execute("call-2", {
252
+ fromIdentifier: RECORDED.subIssue1WithRelation.identifier,
253
+ toIdentifier: RECORDED.subIssue2WithRelation.identifier,
254
+ type: "blocks",
255
+ });
256
+
257
+ expect(mockApi.createIssueRelation).toHaveBeenCalledWith({
258
+ issueId: RECORDED.subIssue1WithRelation.id,
259
+ relatedIssueId: RECORDED.subIssue2WithRelation.id,
260
+ type: "blocks",
261
+ });
262
+ expect(result.data.id).toBe(RECORDED.createRelation.id);
263
+ expect(result.data.type).toBe("blocks");
264
+ });
265
+
266
+ it("plan_get_project shows hierarchy with parent-child nesting", async () => {
267
+ // Return all 3 issues (parent + 2 subs)
268
+ mockApi.getProjectIssues.mockResolvedValueOnce([
269
+ recordedToProjectIssue(RECORDED.parentDetails),
270
+ recordedToProjectIssue(RECORDED.subIssue1WithRelation),
271
+ recordedToProjectIssue(RECORDED.subIssue2WithRelation),
272
+ ]);
273
+
274
+ const tool = findTool("plan_get_project");
275
+ const result = await tool.execute("call-3", {});
276
+ const snapshot = result.data?.snapshot ?? result.data?.plan ?? "";
277
+
278
+ // All three identifiers should appear
279
+ expect(snapshot).toContain(RECORDED.createParent.identifier);
280
+ expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
281
+ expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
282
+ });
283
+
284
+ it("plan_audit passes valid sub-issue hierarchy", async () => {
285
+ // Build issues that pass audit: descriptions >= 50 chars, estimate, priority set
286
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
287
+ priority: 2,
288
+ estimate: 5,
289
+ });
290
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
291
+ priority: 3,
292
+ estimate: 2,
293
+ });
294
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
295
+ priority: 3,
296
+ estimate: 3,
297
+ });
298
+
299
+ mockApi.getProjectIssues.mockResolvedValueOnce([parent, sub1, sub2]);
300
+
301
+ const tool = findTool("plan_audit");
302
+ const result = await tool.execute("call-4", {});
303
+
304
+ expect(result.data.pass).toBe(true);
305
+ expect(result.data.problems).toHaveLength(0);
306
+ });
307
+ });
308
+
309
+ // =========================================================================
310
+ // Group C: auditPlan pure function with recorded data shapes
311
+ // =========================================================================
312
+
313
+ describe("auditPlan with parent-child relationships", () => {
314
+ it("issues with parent are not flagged as orphans", () => {
315
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
316
+ priority: 2,
317
+ estimate: 5,
318
+ });
319
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1Details, {
320
+ priority: 3,
321
+ estimate: 2,
322
+ });
323
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2Details, {
324
+ priority: 3,
325
+ estimate: 3,
326
+ });
327
+
328
+ const result = auditPlan([parent, sub1, sub2]);
329
+
330
+ // Sub-issues have parent set, so they're not orphans.
331
+ // Parent may be flagged as orphan (no parent, no relations linking to it)
332
+ // but sub-issues definitely should NOT be orphans.
333
+ const orphanWarnings = result.warnings.filter((w) =>
334
+ w.includes("orphan"),
335
+ );
336
+ const subOrphans = orphanWarnings.filter(
337
+ (w) =>
338
+ w.includes(RECORDED.subIssue1Details.identifier) ||
339
+ w.includes(RECORDED.subIssue2Details.identifier),
340
+ );
341
+ expect(subOrphans).toHaveLength(0);
342
+ });
343
+
344
+ it("blocks relation between sub-issues produces valid DAG", () => {
345
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
346
+ priority: 3,
347
+ estimate: 2,
348
+ });
349
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
350
+ priority: 3,
351
+ estimate: 3,
352
+ });
353
+
354
+ const cycles = detectCycles([sub1, sub2]);
355
+ expect(cycles).toHaveLength(0);
356
+ });
357
+
358
+ it("buildPlanSnapshot nests sub-issues under parent", () => {
359
+ const parent = recordedToProjectIssue(RECORDED.parentDetails, {
360
+ priority: 2,
361
+ estimate: 5,
362
+ });
363
+ const sub1 = recordedToProjectIssue(RECORDED.subIssue1WithRelation, {
364
+ priority: 3,
365
+ estimate: 2,
366
+ });
367
+ const sub2 = recordedToProjectIssue(RECORDED.subIssue2WithRelation, {
368
+ priority: 3,
369
+ estimate: 3,
370
+ });
371
+
372
+ const snapshot = buildPlanSnapshot([parent, sub1, sub2]);
373
+
374
+ // Parent should appear
375
+ expect(snapshot).toContain(RECORDED.createParent.identifier);
376
+ // Sub-issues should appear
377
+ expect(snapshot).toContain(RECORDED.createSubIssue1.identifier);
378
+ expect(snapshot).toContain(RECORDED.createSubIssue2.identifier);
379
+ // Sub-issues should be indented (nested under parent)
380
+ const lines = snapshot.split("\n");
381
+ const sub1Line = lines.find((l) =>
382
+ l.includes(RECORDED.createSubIssue1.identifier),
383
+ );
384
+ expect(sub1Line).toBeTruthy();
385
+ expect(sub1Line!.startsWith(" ")).toBe(true);
386
+ });
387
+ });
388
+ });
@@ -113,7 +113,7 @@ const {
113
113
  createWorktreeMock: vi.fn().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false }),
114
114
  createMultiWorktreeMock: vi.fn().mockReturnValue({ parentPath: "/tmp/multi", worktrees: [] }),
115
115
  prepareWorkspaceMock: vi.fn().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] }),
116
- resolveReposMock: vi.fn().mockReturnValue({ repos: [{ name: "main", path: "/tmp/test/workspace" }], source: "config_default" }),
116
+ resolveReposMock: vi.fn().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" }),
117
117
  isMultiRepoMock: vi.fn().mockReturnValue(false),
118
118
  ensureClawDirMock: vi.fn(),
119
119
  writeManifestMock: vi.fn(),
@@ -366,7 +366,7 @@ afterEach(() => {
366
366
  clearActiveSessionMock.mockReset();
367
367
  getIssueAffinityMock.mockReset().mockReturnValue(null);
368
368
  configureAffinityTtlMock.mockReset();
369
- resetAffinityMock.mockReset();
369
+ resetAffinityForTestingMock.mockReset();
370
370
  readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
371
371
  getActiveDispatchMock.mockReset().mockReturnValue(null);
372
372
  registerDispatchMock.mockReset().mockResolvedValue(undefined);
@@ -375,7 +375,7 @@ afterEach(() => {
375
375
  assessTierMock.mockReset().mockResolvedValue({ tier: "medium", model: "anthropic/claude-sonnet-4-6", reasoning: "moderate complexity" });
376
376
  createWorktreeMock.mockReset().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false });
377
377
  prepareWorkspaceMock.mockReset().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] });
378
- resolveReposMock.mockReset().mockReturnValue({ repos: [{ name: "main", path: "/tmp/test/workspace" }], source: "config_default" });
378
+ resolveReposMock.mockReset().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" });
379
379
  isMultiRepoMock.mockReset().mockReturnValue(false);
380
380
  ensureClawDirMock.mockReset();
381
381
  writeManifestMock.mockReset();
@@ -4276,8 +4276,8 @@ describe("handleDispatch multi-repo and .catch/.finally", () => {
4276
4276
  });
4277
4277
  resolveReposMock.mockReturnValue({
4278
4278
  repos: [
4279
- { name: "api", path: "/tmp/test/api" },
4280
- { name: "spa", path: "/tmp/test/spa" },
4279
+ { name: "api", path: "/home/claw/api" },
4280
+ { name: "spa", path: "/home/claw/spa" },
4281
4281
  ],
4282
4282
  source: "issue_markers",
4283
4283
  });
@@ -4842,3 +4842,194 @@ describe("handleDispatch error via Issue.update .catch wrapper", () => {
4842
4842
  expect(errorCalls.some((msg: string) => msg.includes("Dispatch pipeline error"))).toBe(true);
4843
4843
  });
4844
4844
  });
4845
+
4846
+ // ---------------------------------------------------------------------------
4847
+ // Session affinity routing
4848
+ // ---------------------------------------------------------------------------
4849
+
4850
+ describe("session affinity routing", () => {
4851
+ it("request_work uses affinity agent instead of default", async () => {
4852
+ getIssueAffinityMock.mockReturnValue("kaylee");
4853
+ classifyIntentMock.mockResolvedValue({
4854
+ intent: "request_work",
4855
+ reasoning: "User wants work done",
4856
+ fromFallback: false,
4857
+ });
4858
+ mockLinearApiInstance.getIssueDetails.mockResolvedValue({
4859
+ id: "issue-affinity-rw",
4860
+ identifier: "ENG-AFF-RW",
4861
+ title: "Affinity Request Work",
4862
+ description: "desc",
4863
+ state: { name: "Backlog", type: "backlog" },
4864
+ team: { id: "team-aff" },
4865
+ comments: { nodes: [] },
4866
+ });
4867
+
4868
+ const result = await postWebhook({
4869
+ type: "Comment",
4870
+ action: "create",
4871
+ data: {
4872
+ id: "comment-affinity-rw",
4873
+ body: "Please implement this",
4874
+ user: { id: "human-aff", name: "Human" },
4875
+ issue: { id: "issue-affinity-rw", identifier: "ENG-AFF-RW" },
4876
+ },
4877
+ });
4878
+
4879
+ expect(result.status).toBe(200);
4880
+ await new Promise((r) => setTimeout(r, 300));
4881
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
4882
+ expect(infoCalls.some((msg: string) => msg.includes("request_work") && msg.includes("kaylee"))).toBe(true);
4883
+ });
4884
+
4885
+ it("null affinity falls through to default agent", async () => {
4886
+ getIssueAffinityMock.mockReturnValue(null);
4887
+ classifyIntentMock.mockResolvedValue({
4888
+ intent: "request_work",
4889
+ reasoning: "User wants work done",
4890
+ fromFallback: false,
4891
+ });
4892
+ mockLinearApiInstance.getIssueDetails.mockResolvedValue({
4893
+ id: "issue-no-aff",
4894
+ identifier: "ENG-NO-AFF",
4895
+ title: "No Affinity",
4896
+ description: "desc",
4897
+ state: { name: "Backlog", type: "backlog" },
4898
+ team: { id: "team-noaff" },
4899
+ comments: { nodes: [] },
4900
+ });
4901
+
4902
+ const result = await postWebhook({
4903
+ type: "Comment",
4904
+ action: "create",
4905
+ data: {
4906
+ id: "comment-no-aff",
4907
+ body: "Do something",
4908
+ user: { id: "human-noaff", name: "Human" },
4909
+ issue: { id: "issue-no-aff", identifier: "ENG-NO-AFF" },
4910
+ },
4911
+ });
4912
+
4913
+ expect(result.status).toBe(200);
4914
+ await new Promise((r) => setTimeout(r, 300));
4915
+ // Default agent is "mal" (from loadAgentProfilesMock isDefault: true)
4916
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
4917
+ expect(infoCalls.some((msg: string) => msg.includes("request_work") && msg.includes("mal"))).toBe(true);
4918
+ });
4919
+
4920
+ it("AgentSessionEvent.created uses affinity when no @mention", async () => {
4921
+ getIssueAffinityMock.mockReturnValue("kaylee");
4922
+
4923
+ const result = await postWebhook({
4924
+ type: "AgentSessionEvent",
4925
+ action: "created",
4926
+ agentSession: {
4927
+ id: "sess-aff-created",
4928
+ issue: { id: "issue-aff-created", identifier: "ENG-AFF-C" },
4929
+ },
4930
+ previousComments: [
4931
+ { body: "Can you investigate?", user: { name: "Dev" } },
4932
+ ],
4933
+ });
4934
+
4935
+ expect(result.status).toBe(200);
4936
+ await new Promise((r) => setTimeout(r, 50));
4937
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
4938
+ expect(infoCalls.some((msg: string) => msg.includes("session affinity") && msg.includes("kaylee"))).toBe(true);
4939
+ });
4940
+
4941
+ it("@mention overrides affinity in AgentSessionEvent.created", async () => {
4942
+ getIssueAffinityMock.mockReturnValue("kaylee");
4943
+ resolveAgentFromAliasMock.mockReturnValue({ agentId: "mal", profile: { label: "Mal" } });
4944
+
4945
+ const result = await postWebhook({
4946
+ type: "AgentSessionEvent",
4947
+ action: "created",
4948
+ agentSession: {
4949
+ id: "sess-mention-override",
4950
+ issue: { id: "issue-mention-override", identifier: "ENG-MO" },
4951
+ },
4952
+ previousComments: [
4953
+ { body: "@mal please fix this", user: { name: "Dev" } },
4954
+ ],
4955
+ });
4956
+
4957
+ expect(result.status).toBe(200);
4958
+ await new Promise((r) => setTimeout(r, 50));
4959
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
4960
+ // @mention should win over affinity
4961
+ expect(infoCalls.some((msg: string) => msg.includes("routed to mal via @mal mention"))).toBe(true);
4962
+ // Affinity should NOT appear in log because @mention took priority
4963
+ expect(infoCalls.some((msg: string) => msg.includes("session affinity"))).toBe(false);
4964
+ });
4965
+
4966
+ it("close_issue uses affinity agent", async () => {
4967
+ getIssueAffinityMock.mockReturnValue("kaylee");
4968
+ classifyIntentMock.mockResolvedValue({
4969
+ intent: "close_issue",
4970
+ reasoning: "User wants to close",
4971
+ fromFallback: false,
4972
+ });
4973
+ mockLinearApiInstance.getIssueDetails.mockResolvedValue({
4974
+ id: "issue-aff-close",
4975
+ identifier: "ENG-AFF-CL",
4976
+ title: "Affinity Close",
4977
+ description: "desc",
4978
+ state: { name: "In Progress", type: "started" },
4979
+ team: { id: "team-aff-cl" },
4980
+ comments: { nodes: [] },
4981
+ });
4982
+
4983
+ const result = await postWebhook({
4984
+ type: "Comment",
4985
+ action: "create",
4986
+ data: {
4987
+ id: "comment-aff-close",
4988
+ body: "close this please",
4989
+ user: { id: "human-aff-cl", name: "Human" },
4990
+ issue: { id: "issue-aff-close", identifier: "ENG-AFF-CL" },
4991
+ },
4992
+ });
4993
+
4994
+ expect(result.status).toBe(200);
4995
+ await new Promise((r) => setTimeout(r, 300));
4996
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
4997
+ expect(infoCalls.some((msg: string) => msg.includes("close_issue") && msg.includes("kaylee"))).toBe(true);
4998
+ });
4999
+
5000
+ it("ask_agent with explicit agentId overrides affinity", async () => {
5001
+ getIssueAffinityMock.mockReturnValue("kaylee");
5002
+ classifyIntentMock.mockResolvedValue({
5003
+ intent: "ask_agent",
5004
+ agentId: "mal",
5005
+ reasoning: "User asked mal explicitly",
5006
+ fromFallback: false,
5007
+ });
5008
+ mockLinearApiInstance.getIssueDetails.mockResolvedValue({
5009
+ id: "issue-ask-override",
5010
+ identifier: "ENG-ASK-O",
5011
+ title: "Ask Agent Override",
5012
+ description: "desc",
5013
+ state: { name: "Backlog", type: "backlog" },
5014
+ team: { id: "team-ask-o" },
5015
+ comments: { nodes: [] },
5016
+ });
5017
+
5018
+ const result = await postWebhook({
5019
+ type: "Comment",
5020
+ action: "create",
5021
+ data: {
5022
+ id: "comment-ask-override",
5023
+ body: "@mal what do you think?",
5024
+ user: { id: "human-ask-o", name: "Human" },
5025
+ issue: { id: "issue-ask-override", identifier: "ENG-ASK-O" },
5026
+ },
5027
+ });
5028
+
5029
+ expect(result.status).toBe(200);
5030
+ await new Promise((r) => setTimeout(r, 300));
5031
+ const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
5032
+ // ask_agent uses intentResult.agentId directly, not affinity
5033
+ expect(infoCalls.some((msg: string) => msg.includes("ask_agent") && msg.includes("mal"))).toBe(true);
5034
+ });
5035
+ });
@@ -336,6 +336,7 @@ export async function handleLinearWebhook(
336
336
  const profiles = loadAgentProfiles();
337
337
  const mentionPattern = buildMentionPattern(profiles);
338
338
  let agentId = resolveAgentId(api);
339
+ let mentionOverride = false;
339
340
  if (mentionPattern && userMessage) {
340
341
  const mentionMatch = userMessage.match(mentionPattern);
341
342
  if (mentionMatch) {
@@ -344,11 +345,12 @@ export async function handleLinearWebhook(
344
345
  if (resolved) {
345
346
  api.logger.info(`AgentSession routed to ${resolved.agentId} via @${alias} mention`);
346
347
  agentId = resolved.agentId;
348
+ mentionOverride = true;
347
349
  }
348
350
  }
349
351
  }
350
352
  // Session affinity: if no @mention override, prefer the agent that last handled this issue
351
- if (agentId === resolveAgentId(api) && issue?.id) {
353
+ if (!mentionOverride && issue?.id) {
352
354
  const affinityAgent = getIssueAffinity(issue.id);
353
355
  if (affinityAgent) {
354
356
  api.logger.info(`AgentSession routed to ${affinityAgent} via session affinity for ${issue.identifier ?? issue.id}`);
@@ -555,6 +557,7 @@ export async function handleLinearWebhook(
555
557
  const promptedProfiles = loadAgentProfiles();
556
558
  const promptedMentionPattern = buildMentionPattern(promptedProfiles);
557
559
  let agentId = resolveAgentId(api);
560
+ let mentionOverride = false;
558
561
  if (promptedMentionPattern && userMessage) {
559
562
  const mentionMatch = userMessage.match(promptedMentionPattern);
560
563
  if (mentionMatch) {
@@ -563,11 +566,12 @@ export async function handleLinearWebhook(
563
566
  if (resolved) {
564
567
  api.logger.info(`AgentSession prompted: routed to ${resolved.agentId} via @${alias} mention`);
565
568
  agentId = resolved.agentId;
569
+ mentionOverride = true;
566
570
  }
567
571
  }
568
572
  }
569
573
  // Session affinity: if no @mention override, prefer the agent that last handled this issue
570
- if (agentId === resolveAgentId(api) && issue?.id) {
574
+ if (!mentionOverride && issue?.id) {
571
575
  const affinityAgent = getIssueAffinity(issue.id);
572
576
  if (affinityAgent) {
573
577
  api.logger.info(`AgentSession prompted: routed to ${affinityAgent} via session affinity for ${issue.identifier ?? issue.id}`);