@calltelemetry/openclaw-linear 0.9.5 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,172 +1,254 @@
1
1
  /**
2
2
  * Recorded API responses from sub-issue decomposition smoke test.
3
- *
4
- * Initially seeded with placeholder data. Overwritten with real API responses
5
- * when the smoke test runs: npx vitest run src/__test__/smoke-linear-api.test.ts
6
- *
7
- * Last recorded: seed (placeholder)
3
+ * Auto-generated — do not edit manually.
4
+ * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
5
+ * Last recorded: 2026-02-22T01:35:48.289Z
8
6
  */
9
7
 
10
8
  export const RECORDED = {
11
- teamStates: [
12
- { id: "state-backlog", name: "Backlog", type: "backlog" },
13
- { id: "state-started", name: "In Progress", type: "started" },
14
- { id: "state-done", name: "Done", type: "completed" },
15
- { id: "state-canceled", name: "Canceled", type: "canceled" },
16
- ],
17
- createParent: { id: "parent-001", identifier: "UAT-100" },
18
- createSubIssue1: { id: "sub1-001", identifier: "UAT-101" },
19
- createSubIssue2: { id: "sub2-001", identifier: "UAT-102" },
20
- parentDetails: {
21
- id: "parent-001",
22
- identifier: "UAT-100",
23
- title: "[SMOKE TEST] Sub-Issue Parent: Search Feature",
24
- description:
25
- "Auto-generated by smoke test to verify sub-issue decomposition.\n\n" +
26
- "This parent issue should have two sub-issues created under it.",
27
- estimate: null,
28
- state: { name: "Backlog", type: "backlog" },
29
- creator: { name: "Test User", email: null as string | null },
30
- assignee: null as { name: string } | null,
31
- labels: { nodes: [] as Array<{ id: string; name: string }> },
32
- team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
33
- comments: {
34
- nodes: [] as Array<{
35
- body: string;
36
- user: { name: string } | null;
37
- createdAt: string;
38
- }>,
39
- },
40
- project: null as { id: string; name: string } | null,
41
- parent: null as { id: string; identifier: string } | null,
42
- relations: {
43
- nodes: [] as Array<{
44
- type: string;
45
- relatedIssue: { id: string; identifier: string; title: string };
46
- }>,
9
+ "teamStates": [
10
+ {
11
+ "id": "cf937197-a76e-4f36-9938-5013442f2137",
12
+ "name": "Todo",
13
+ "type": "unstarted"
14
+ },
15
+ {
16
+ "id": "9e40430a-5404-4fed-be64-544417c7c46c",
17
+ "name": "Duplicate",
18
+ "type": "canceled"
19
+ },
20
+ {
21
+ "id": "9bf913d6-f32d-452f-af43-3139cc8e5f3f",
22
+ "name": "Canceled",
23
+ "type": "canceled"
24
+ },
25
+ {
26
+ "id": "7510263f-555b-42c4-98c3-f4196d755c4b",
27
+ "name": "In Progress",
28
+ "type": "started"
29
+ },
30
+ {
31
+ "id": "6bcb2601-dc7b-487e-a407-416521aa235e",
32
+ "name": "Backlog",
33
+ "type": "backlog"
47
34
  },
35
+ {
36
+ "id": "69c39ddf-d135-47f7-8dbe-3fc6a539d504",
37
+ "name": "Done",
38
+ "type": "completed"
39
+ },
40
+ {
41
+ "id": "2a4c7f41-80cd-4798-91e7-0aa6656d73ca",
42
+ "name": "In Review",
43
+ "type": "started"
44
+ }
45
+ ],
46
+ "createParent": {
47
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
48
+ "identifier": "UAT-235"
49
+ },
50
+ "createSubIssue1": {
51
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
52
+ "identifier": "UAT-236"
48
53
  },
49
- subIssue1Details: {
50
- id: "sub1-001",
51
- identifier: "UAT-101",
52
- title: "[SMOKE TEST] Sub-Issue 1: Backend API",
53
- description:
54
- "Implement the backend search API endpoint.\n\n" +
55
- "Given a search query, when the API is called, then matching results are returned.",
56
- estimate: 2,
57
- state: { name: "Backlog", type: "backlog" },
58
- creator: { name: "Test User", email: null as string | null },
59
- assignee: null as { name: string } | null,
60
- labels: { nodes: [] as Array<{ id: string; name: string }> },
61
- team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
62
- comments: {
63
- nodes: [] as Array<{
64
- body: string;
65
- user: { name: string } | null;
66
- createdAt: string;
67
- }>,
68
- },
69
- project: null as { id: string; name: string } | null,
70
- parent: { id: "parent-001", identifier: "UAT-100" },
71
- relations: {
72
- nodes: [] as Array<{
73
- type: string;
74
- relatedIssue: { id: string; identifier: string; title: string };
75
- }>,
54
+ "createSubIssue2": {
55
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
56
+ "identifier": "UAT-237"
57
+ },
58
+ "subIssue1Details": {
59
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
60
+ "identifier": "UAT-236",
61
+ "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
62
+ "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
63
+ "estimate": 2,
64
+ "state": {
65
+ "name": "Backlog",
66
+ "type": "backlog"
67
+ },
68
+ "creator": {
69
+ "name": "jason.barbee@calltelemetry.com",
70
+ "email": "jason.barbee@calltelemetry.com"
76
71
  },
72
+ "assignee": null,
73
+ "labels": {
74
+ "nodes": []
75
+ },
76
+ "team": {
77
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
78
+ "name": "UAT",
79
+ "issueEstimationType": "tShirt"
80
+ },
81
+ "comments": {
82
+ "nodes": []
83
+ },
84
+ "project": null,
85
+ "parent": {
86
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
87
+ "identifier": "UAT-235"
88
+ },
89
+ "relations": {
90
+ "nodes": []
91
+ }
77
92
  },
78
- subIssue2Details: {
79
- id: "sub2-001",
80
- identifier: "UAT-102",
81
- title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
82
- description:
83
- "Build the frontend search UI component.\n\n" +
84
- "Given the search page loads, when the user types a query, then results display in real-time.",
85
- estimate: 3,
86
- state: { name: "Backlog", type: "backlog" },
87
- creator: { name: "Test User", email: null as string | null },
88
- assignee: null as { name: string } | null,
89
- labels: { nodes: [] as Array<{ id: string; name: string }> },
90
- team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
91
- comments: {
92
- nodes: [] as Array<{
93
- body: string;
94
- user: { name: string } | null;
95
- createdAt: string;
96
- }>,
97
- },
98
- project: null as { id: string; name: string } | null,
99
- parent: { id: "parent-001", identifier: "UAT-100" },
100
- relations: {
101
- nodes: [] as Array<{
102
- type: string;
103
- relatedIssue: { id: string; identifier: string; title: string };
104
- }>,
93
+ "subIssue2Details": {
94
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
95
+ "identifier": "UAT-237",
96
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
97
+ "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
98
+ "estimate": 3,
99
+ "state": {
100
+ "name": "Backlog",
101
+ "type": "backlog"
102
+ },
103
+ "creator": {
104
+ "name": "jason.barbee@calltelemetry.com",
105
+ "email": "jason.barbee@calltelemetry.com"
106
+ },
107
+ "assignee": null,
108
+ "labels": {
109
+ "nodes": []
110
+ },
111
+ "team": {
112
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
113
+ "name": "UAT",
114
+ "issueEstimationType": "tShirt"
115
+ },
116
+ "comments": {
117
+ "nodes": []
118
+ },
119
+ "project": null,
120
+ "parent": {
121
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
122
+ "identifier": "UAT-235"
105
123
  },
124
+ "relations": {
125
+ "nodes": []
126
+ }
106
127
  },
107
- createRelation: { id: "rel-001" },
108
- subIssue1WithRelation: {
109
- id: "sub1-001",
110
- identifier: "UAT-101",
111
- title: "[SMOKE TEST] Sub-Issue 1: Backend API",
112
- description:
113
- "Implement the backend search API endpoint.\n\n" +
114
- "Given a search query, when the API is called, then matching results are returned.",
115
- estimate: 2,
116
- state: { name: "Backlog", type: "backlog" },
117
- creator: { name: "Test User", email: null as string | null },
118
- assignee: null as { name: string } | null,
119
- labels: { nodes: [] as Array<{ id: string; name: string }> },
120
- team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
121
- comments: {
122
- nodes: [] as Array<{
123
- body: string;
124
- user: { name: string } | null;
125
- createdAt: string;
126
- }>,
127
- },
128
- project: null as { id: string; name: string } | null,
129
- parent: { id: "parent-001", identifier: "UAT-100" },
130
- relations: {
131
- nodes: [
128
+ "parentDetails": {
129
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
130
+ "identifier": "UAT-235",
131
+ "title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
132
+ "description": "Auto-generated by smoke test to verify sub-issue decomposition.\n\nThis parent issue should have two sub-issues created under it.\n\nCreated: 2026-02-22T01:35:46.183Z",
133
+ "estimate": null,
134
+ "state": {
135
+ "name": "Backlog",
136
+ "type": "backlog"
137
+ },
138
+ "creator": {
139
+ "name": "jason.barbee@calltelemetry.com",
140
+ "email": "jason.barbee@calltelemetry.com"
141
+ },
142
+ "assignee": null,
143
+ "labels": {
144
+ "nodes": []
145
+ },
146
+ "team": {
147
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
148
+ "name": "UAT",
149
+ "issueEstimationType": "tShirt"
150
+ },
151
+ "comments": {
152
+ "nodes": [
132
153
  {
133
- type: "blocks",
134
- relatedIssue: {
135
- id: "sub2-001",
136
- identifier: "UAT-102",
137
- title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
138
- },
139
- },
140
- ],
154
+ "body": "This thread is for an agent session with ctclaw.",
155
+ "user": null,
156
+ "createdAt": "2026-02-22T01:35:47.210Z"
157
+ }
158
+ ]
141
159
  },
160
+ "project": null,
161
+ "parent": null,
162
+ "relations": {
163
+ "nodes": []
164
+ }
165
+ },
166
+ "createRelation": {
167
+ "id": "f2b33a57-b79a-4dcc-b973-a5453676a78f"
142
168
  },
143
- subIssue2WithRelation: {
144
- id: "sub2-001",
145
- identifier: "UAT-102",
146
- title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
147
- description:
148
- "Build the frontend search UI component.\n\n" +
149
- "Given the search page loads, when the user types a query, then results display in real-time.",
150
- estimate: 3,
151
- state: { name: "Backlog", type: "backlog" },
152
- creator: { name: "Test User", email: null as string | null },
153
- assignee: null as { name: string } | null,
154
- labels: { nodes: [] as Array<{ id: string; name: string }> },
155
- team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
156
- comments: {
157
- nodes: [] as Array<{
158
- body: string;
159
- user: { name: string } | null;
160
- createdAt: string;
161
- }>,
162
- },
163
- project: null as { id: string; name: string } | null,
164
- parent: { id: "parent-001", identifier: "UAT-100" },
165
- relations: {
166
- nodes: [] as Array<{
167
- type: string;
168
- relatedIssue: { id: string; identifier: string; title: string };
169
- }>,
169
+ "subIssue1WithRelation": {
170
+ "id": "2796434d-9c1e-4ee9-af70-6e0e14fe6109",
171
+ "identifier": "UAT-236",
172
+ "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
173
+ "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
174
+ "estimate": 2,
175
+ "state": {
176
+ "name": "Backlog",
177
+ "type": "backlog"
178
+ },
179
+ "creator": {
180
+ "name": "jason.barbee@calltelemetry.com",
181
+ "email": "jason.barbee@calltelemetry.com"
182
+ },
183
+ "assignee": null,
184
+ "labels": {
185
+ "nodes": []
186
+ },
187
+ "team": {
188
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
189
+ "name": "UAT",
190
+ "issueEstimationType": "tShirt"
191
+ },
192
+ "comments": {
193
+ "nodes": [
194
+ {
195
+ "body": "This thread is for an agent session with ctclaw.",
196
+ "user": null,
197
+ "createdAt": "2026-02-22T01:35:47.302Z"
198
+ }
199
+ ]
200
+ },
201
+ "project": null,
202
+ "parent": {
203
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
204
+ "identifier": "UAT-235"
170
205
  },
206
+ "relations": {
207
+ "nodes": [
208
+ {
209
+ "type": "blocks",
210
+ "relatedIssue": {
211
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
212
+ "identifier": "UAT-237",
213
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
214
+ }
215
+ }
216
+ ]
217
+ }
171
218
  },
219
+ "subIssue2WithRelation": {
220
+ "id": "d1b939d0-33f1-4f6d-992a-95f2a3cb842a",
221
+ "identifier": "UAT-237",
222
+ "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
223
+ "description": "Build the frontend search UI component.\n\nGiven the search page loads, when the user types a query, then results display in real-time.",
224
+ "estimate": 3,
225
+ "state": {
226
+ "name": "Backlog",
227
+ "type": "backlog"
228
+ },
229
+ "creator": {
230
+ "name": "jason.barbee@calltelemetry.com",
231
+ "email": "jason.barbee@calltelemetry.com"
232
+ },
233
+ "assignee": null,
234
+ "labels": {
235
+ "nodes": []
236
+ },
237
+ "team": {
238
+ "id": "08cba264-d774-4afd-bc93-ee8213d12ef8",
239
+ "name": "UAT",
240
+ "issueEstimationType": "tShirt"
241
+ },
242
+ "comments": {
243
+ "nodes": []
244
+ },
245
+ "project": null,
246
+ "parent": {
247
+ "id": "e49ed5fb-8068-41a8-9f4d-6d9b55a0c2b6",
248
+ "identifier": "UAT-235"
249
+ },
250
+ "relations": {
251
+ "nodes": []
252
+ }
253
+ }
172
254
  };
@@ -8,12 +8,15 @@
8
8
  *
9
9
  * Requires: ~/.openclaw/auth-profiles.json with a valid linear:api-key profile.
10
10
  */
11
- import { readFileSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
15
16
  import { LinearAgentApi } from "../api/linear-api.js";
16
17
 
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
17
20
  // ── Setup ──────────────────────────────────────────────────────────
18
21
 
19
22
  const AUTH_PROFILES_PATH = join(
@@ -479,6 +482,208 @@ describe("Linear API smoke tests", () => {
479
482
  });
480
483
  });
481
484
 
485
+ describe("sub-issue decomposition", () => {
486
+ let parentIssueId: string | null = null;
487
+ let parentIssueIdentifier: string | null = null;
488
+ let subIssue1Id: string | null = null;
489
+ let subIssue1Identifier: string | null = null;
490
+ let subIssue2Id: string | null = null;
491
+ let subIssue2Identifier: string | null = null;
492
+ const recorded: Record<string, unknown> = {};
493
+
494
+ let teamStates: Array<{ id: string; name: string; type: string }>;
495
+
496
+ it("fetches team states for sub-issue tests", async () => {
497
+ teamStates = await api.getTeamStates(TEAM_ID);
498
+ expect(teamStates.length).toBeGreaterThan(0);
499
+ recorded.teamStates = teamStates;
500
+ });
501
+
502
+ it("creates a parent issue", async () => {
503
+ const backlogState = teamStates.find((s) => s.type === "backlog");
504
+ expect(backlogState).toBeTruthy();
505
+
506
+ const result = await api.createIssue({
507
+ teamId: TEAM_ID,
508
+ title: "[SMOKE TEST] Sub-Issue Parent: Search Feature",
509
+ description:
510
+ "Auto-generated by smoke test to verify sub-issue decomposition.\n\n" +
511
+ "This parent issue should have two sub-issues created under it.\n\n" +
512
+ `Created: ${new Date().toISOString()}`,
513
+ stateId: backlogState!.id,
514
+ priority: 4,
515
+ });
516
+
517
+ expect(result.id).toBeTruthy();
518
+ expect(result.identifier).toBeTruthy();
519
+ parentIssueId = result.id;
520
+ parentIssueIdentifier = result.identifier;
521
+ recorded.createParent = result;
522
+ console.log(`Created parent issue: ${result.identifier} (${result.id})`);
523
+ });
524
+
525
+ it("creates sub-issue 1 under parent (Backend API)", async () => {
526
+ expect(parentIssueId).toBeTruthy();
527
+ const backlogState = teamStates.find((s) => s.type === "backlog");
528
+
529
+ const result = await api.createIssue({
530
+ teamId: TEAM_ID,
531
+ title: "[SMOKE TEST] Sub-Issue 1: Backend API",
532
+ description:
533
+ "Implement the backend search API endpoint.\n\n" +
534
+ "Given a search query, when the API is called, then matching results are returned.",
535
+ stateId: backlogState!.id,
536
+ parentId: parentIssueId!,
537
+ priority: 3,
538
+ estimate: 2,
539
+ });
540
+
541
+ expect(result.id).toBeTruthy();
542
+ expect(result.identifier).toBeTruthy();
543
+ subIssue1Id = result.id;
544
+ subIssue1Identifier = result.identifier;
545
+ recorded.createSubIssue1 = result;
546
+ console.log(`Created sub-issue 1: ${result.identifier} (parentId=${parentIssueId})`);
547
+ });
548
+
549
+ it("creates sub-issue 2 under parent (Frontend UI)", async () => {
550
+ expect(parentIssueId).toBeTruthy();
551
+ const backlogState = teamStates.find((s) => s.type === "backlog");
552
+
553
+ const result = await api.createIssue({
554
+ teamId: TEAM_ID,
555
+ title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
556
+ description:
557
+ "Build the frontend search UI component.\n\n" +
558
+ "Given the search page loads, when the user types a query, then results display in real-time.",
559
+ stateId: backlogState!.id,
560
+ parentId: parentIssueId!,
561
+ priority: 3,
562
+ estimate: 3,
563
+ });
564
+
565
+ expect(result.id).toBeTruthy();
566
+ expect(result.identifier).toBeTruthy();
567
+ subIssue2Id = result.id;
568
+ subIssue2Identifier = result.identifier;
569
+ recorded.createSubIssue2 = result;
570
+ console.log(`Created sub-issue 2: ${result.identifier} (parentId=${parentIssueId})`);
571
+ });
572
+
573
+ it("sub-issue 1 has parent field pointing to parent", async () => {
574
+ expect(subIssue1Id).toBeTruthy();
575
+ const details = await api.getIssueDetails(subIssue1Id!);
576
+
577
+ expect(details.parent).not.toBeNull();
578
+ expect(details.parent!.id).toBe(parentIssueId);
579
+ expect(details.parent!.identifier).toBe(parentIssueIdentifier);
580
+ recorded.subIssue1Details = details;
581
+ console.log(`Sub-issue 1 parent: ${details.parent!.identifier}`);
582
+ });
583
+
584
+ it("sub-issue 2 has parent field pointing to parent", async () => {
585
+ expect(subIssue2Id).toBeTruthy();
586
+ const details = await api.getIssueDetails(subIssue2Id!);
587
+
588
+ expect(details.parent).not.toBeNull();
589
+ expect(details.parent!.id).toBe(parentIssueId);
590
+ expect(details.parent!.identifier).toBe(parentIssueIdentifier);
591
+ recorded.subIssue2Details = details;
592
+ console.log(`Sub-issue 2 parent: ${details.parent!.identifier}`);
593
+ });
594
+
595
+ it("parent issue has no parent (it is the root)", async () => {
596
+ expect(parentIssueId).toBeTruthy();
597
+ const details = await api.getIssueDetails(parentIssueId!);
598
+
599
+ expect(details.parent).toBeNull();
600
+ recorded.parentDetails = details;
601
+ });
602
+
603
+ it("creates blocks relation (sub-issue 1 blocks sub-issue 2)", async () => {
604
+ expect(subIssue1Id).toBeTruthy();
605
+ expect(subIssue2Id).toBeTruthy();
606
+
607
+ const result = await api.createIssueRelation({
608
+ issueId: subIssue1Id!,
609
+ relatedIssueId: subIssue2Id!,
610
+ type: "blocks",
611
+ });
612
+
613
+ expect(result.id).toBeTruthy();
614
+ recorded.createRelation = result;
615
+ console.log(`Created blocks relation: ${subIssue1Identifier} blocks ${subIssue2Identifier}`);
616
+ });
617
+
618
+ it("sub-issue 1 shows blocks relation to sub-issue 2", async () => {
619
+ expect(subIssue1Id).toBeTruthy();
620
+ const details = await api.getIssueDetails(subIssue1Id!);
621
+ const blocksRels = details.relations.nodes.filter(
622
+ (r) => r.type === "blocks",
623
+ );
624
+
625
+ expect(blocksRels.length).toBeGreaterThan(0);
626
+ expect(
627
+ blocksRels.some((r) => r.relatedIssue.id === subIssue2Id),
628
+ ).toBe(true);
629
+ recorded.subIssue1WithRelation = details;
630
+ console.log(`Sub-issue 1 blocks: ${blocksRels.map((r) => r.relatedIssue.identifier).join(", ")}`);
631
+ });
632
+
633
+ it("sub-issue 2 shows inverse relation", async () => {
634
+ expect(subIssue2Id).toBeTruthy();
635
+ const details = await api.getIssueDetails(subIssue2Id!);
636
+
637
+ // Linear may or may not populate an inverse "is_blocked_by" relation.
638
+ // Record whatever we get — the mock test replays it as-is.
639
+ recorded.subIssue2WithRelation = details;
640
+ const rels = details.relations.nodes;
641
+ console.log(
642
+ `Sub-issue 2 relations: ${rels.length > 0 ? rels.map((r) => `${r.type} ${r.relatedIssue.identifier}`).join(", ") : "(none — inverse may not be returned)"}`,
643
+ );
644
+ });
645
+
646
+ it("saves recorded API responses to fixture file", () => {
647
+ // Only write if we actually ran the full flow
648
+ if (!parentIssueId || !subIssue1Id || !subIssue2Id) {
649
+ console.log("Skipping fixture write — not all issues were created.");
650
+ return;
651
+ }
652
+
653
+ const fixturePath = join(
654
+ __dirname,
655
+ "fixtures",
656
+ "recorded-sub-issue-flow.ts",
657
+ );
658
+ const content =
659
+ `/**\n` +
660
+ ` * Recorded API responses from sub-issue decomposition smoke test.\n` +
661
+ ` * Auto-generated — do not edit manually.\n` +
662
+ ` * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts\n` +
663
+ ` * Last recorded: ${new Date().toISOString()}\n` +
664
+ ` */\n\n` +
665
+ `export const RECORDED = ${JSON.stringify(recorded, null, 2)};\n`;
666
+
667
+ writeFileSync(fixturePath, content, "utf8");
668
+ console.log(`Recorded fixture written to: ${fixturePath}`);
669
+ });
670
+
671
+ it("cleans up: cancels parent and sub-issues", async () => {
672
+ const canceledState = teamStates?.find(
673
+ (s) => s.type === "canceled" || s.name.toLowerCase().includes("cancel"),
674
+ );
675
+
676
+ for (const id of [subIssue1Id, subIssue2Id, parentIssueId]) {
677
+ if (!id || !canceledState) continue;
678
+ try {
679
+ await api.updateIssue(id, { stateId: canceledState.id });
680
+ } catch {
681
+ // Best effort
682
+ }
683
+ }
684
+ });
685
+ });
686
+
482
687
  describe("cleanup", () => {
483
688
  it("cancels the smoke test issue", async () => {
484
689
  if (!smokeIssueId) return;
@@ -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
+ });
@@ -24,6 +24,9 @@ const {
24
24
  resetGuidanceCacheMock,
25
25
  setActiveSessionMock,
26
26
  clearActiveSessionMock,
27
+ getIssueAffinityMock,
28
+ configureAffinityTtlMock,
29
+ resetAffinityForTestingMock,
27
30
  readDispatchStateMock,
28
31
  getActiveDispatchMock,
29
32
  registerDispatchMock,
@@ -97,6 +100,9 @@ const {
97
100
  resetGuidanceCacheMock: vi.fn(),
98
101
  setActiveSessionMock: vi.fn(),
99
102
  clearActiveSessionMock: vi.fn(),
103
+ getIssueAffinityMock: vi.fn().mockReturnValue(null),
104
+ configureAffinityTtlMock: vi.fn(),
105
+ resetAffinityForTestingMock: vi.fn(),
100
106
  readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
101
107
  getActiveDispatchMock: vi.fn().mockReturnValue(null),
102
108
  registerDispatchMock: vi.fn().mockResolvedValue(undefined),
@@ -167,9 +173,9 @@ vi.mock("./guidance.js", () => ({
167
173
  vi.mock("./active-session.js", () => ({
168
174
  setActiveSession: setActiveSessionMock,
169
175
  clearActiveSession: clearActiveSessionMock,
170
- getIssueAffinity: vi.fn().mockReturnValue(null),
171
- _configureAffinityTtl: vi.fn(),
172
- _resetAffinityForTesting: vi.fn(),
176
+ getIssueAffinity: getIssueAffinityMock,
177
+ _configureAffinityTtl: configureAffinityTtlMock,
178
+ _resetAffinityForTesting: resetAffinityForTestingMock,
173
179
  }));
174
180
 
175
181
  vi.mock("./dispatch-state.js", () => ({
@@ -358,6 +364,9 @@ afterEach(() => {
358
364
  isGuidanceEnabledMock.mockReset().mockReturnValue(false);
359
365
  setActiveSessionMock.mockReset();
360
366
  clearActiveSessionMock.mockReset();
367
+ getIssueAffinityMock.mockReset().mockReturnValue(null);
368
+ configureAffinityTtlMock.mockReset();
369
+ resetAffinityForTestingMock.mockReset();
361
370
  readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
362
371
  getActiveDispatchMock.mockReset().mockReturnValue(null);
363
372
  registerDispatchMock.mockReset().mockResolvedValue(undefined);
@@ -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}`);