@calltelemetry/openclaw-linear 0.9.9 → 0.9.11

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.9",
3
+ "version": "0.9.11",
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",
@@ -2,7 +2,7 @@
2
2
  * Recorded API responses from sub-issue decomposition smoke test.
3
3
  * Auto-generated — do not edit manually.
4
4
  * Re-generate by running: npx vitest run src/__test__/smoke-linear-api.test.ts
5
- * Last recorded: 2026-02-22T02:27:42.743Z
5
+ * Last recorded: 2026-02-22T02:38:53.901Z
6
6
  */
7
7
 
8
8
  export const RECORDED = {
@@ -44,20 +44,20 @@ export const RECORDED = {
44
44
  }
45
45
  ],
46
46
  "createParent": {
47
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
48
- "identifier": "UAT-305"
47
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
48
+ "identifier": "UAT-320"
49
49
  },
50
50
  "createSubIssue1": {
51
- "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
52
- "identifier": "UAT-306"
51
+ "id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
52
+ "identifier": "UAT-321"
53
53
  },
54
54
  "createSubIssue2": {
55
- "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
56
- "identifier": "UAT-307"
55
+ "id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
56
+ "identifier": "UAT-322"
57
57
  },
58
58
  "subIssue1Details": {
59
- "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
60
- "identifier": "UAT-306",
59
+ "id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
60
+ "identifier": "UAT-321",
61
61
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
62
62
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
63
63
  "estimate": 2,
@@ -83,16 +83,16 @@ export const RECORDED = {
83
83
  },
84
84
  "project": null,
85
85
  "parent": {
86
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
87
- "identifier": "UAT-305"
86
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
87
+ "identifier": "UAT-320"
88
88
  },
89
89
  "relations": {
90
90
  "nodes": []
91
91
  }
92
92
  },
93
93
  "subIssue2Details": {
94
- "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
95
- "identifier": "UAT-307",
94
+ "id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
95
+ "identifier": "UAT-322",
96
96
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
97
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
98
  "estimate": 3,
@@ -114,28 +114,22 @@ export const RECORDED = {
114
114
  "issueEstimationType": "tShirt"
115
115
  },
116
116
  "comments": {
117
- "nodes": [
118
- {
119
- "body": "This thread is for an agent session with ctclaw.",
120
- "user": null,
121
- "createdAt": "2026-02-22T02:27:41.198Z"
122
- }
123
- ]
117
+ "nodes": []
124
118
  },
125
119
  "project": null,
126
120
  "parent": {
127
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
128
- "identifier": "UAT-305"
121
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
122
+ "identifier": "UAT-320"
129
123
  },
130
124
  "relations": {
131
125
  "nodes": []
132
126
  }
133
127
  },
134
128
  "parentDetails": {
135
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
136
- "identifier": "UAT-305",
129
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
130
+ "identifier": "UAT-320",
137
131
  "title": "[SMOKE TEST] Sub-Issue Parent: Search Feature",
138
- "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-22T02:27:39.194Z",
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-22T02:38:52.132Z",
139
133
  "estimate": null,
140
134
  "state": {
141
135
  "name": "Backlog",
@@ -155,13 +149,7 @@ export const RECORDED = {
155
149
  "issueEstimationType": "tShirt"
156
150
  },
157
151
  "comments": {
158
- "nodes": [
159
- {
160
- "body": "This thread is for an agent session with ctclaw.",
161
- "user": null,
162
- "createdAt": "2026-02-22T02:27:39.821Z"
163
- }
164
- ]
152
+ "nodes": []
165
153
  },
166
154
  "project": null,
167
155
  "parent": null,
@@ -170,11 +158,11 @@ export const RECORDED = {
170
158
  }
171
159
  },
172
160
  "createRelation": {
173
- "id": "b7040537-aa53-4e7d-a76d-fd220df3e527"
161
+ "id": "9207d26d-d65d-45a2-ac76-b9233acea181"
174
162
  },
175
163
  "subIssue1WithRelation": {
176
- "id": "f342ec90-c5f1-483d-987b-9de406d65fac",
177
- "identifier": "UAT-306",
164
+ "id": "69ce9a01-3bdb-4f4a-a882-e28e23d1b628",
165
+ "identifier": "UAT-321",
178
166
  "title": "[SMOKE TEST] Sub-Issue 1: Backend API",
179
167
  "description": "Implement the backend search API endpoint.\n\nGiven a search query, when the API is called, then matching results are returned.",
180
168
  "estimate": 2,
@@ -196,26 +184,20 @@ export const RECORDED = {
196
184
  "issueEstimationType": "tShirt"
197
185
  },
198
186
  "comments": {
199
- "nodes": [
200
- {
201
- "body": "This thread is for an agent session with ctclaw.",
202
- "user": null,
203
- "createdAt": "2026-02-22T02:27:40.662Z"
204
- }
205
- ]
187
+ "nodes": []
206
188
  },
207
189
  "project": null,
208
190
  "parent": {
209
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
210
- "identifier": "UAT-305"
191
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
192
+ "identifier": "UAT-320"
211
193
  },
212
194
  "relations": {
213
195
  "nodes": [
214
196
  {
215
197
  "type": "blocks",
216
198
  "relatedIssue": {
217
- "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
218
- "identifier": "UAT-307",
199
+ "id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
200
+ "identifier": "UAT-322",
219
201
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI"
220
202
  }
221
203
  }
@@ -223,8 +205,8 @@ export const RECORDED = {
223
205
  }
224
206
  },
225
207
  "subIssue2WithRelation": {
226
- "id": "210a5f52-a2bd-41a9-871f-b92563446d06",
227
- "identifier": "UAT-307",
208
+ "id": "b39cc5eb-ec05-44aa-917b-562a290ecab6",
209
+ "identifier": "UAT-322",
228
210
  "title": "[SMOKE TEST] Sub-Issue 2: Frontend UI",
229
211
  "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.",
230
212
  "estimate": 3,
@@ -250,14 +232,14 @@ export const RECORDED = {
250
232
  {
251
233
  "body": "This thread is for an agent session with ctclaw.",
252
234
  "user": null,
253
- "createdAt": "2026-02-22T02:27:41.198Z"
235
+ "createdAt": "2026-02-22T02:38:53.699Z"
254
236
  }
255
237
  ]
256
238
  },
257
239
  "project": null,
258
240
  "parent": {
259
- "id": "fa2dd666-d1bc-4d53-bc18-db2d02caef96",
260
- "identifier": "UAT-305"
241
+ "id": "953324f8-878f-40ba-b773-8d8815ee7987",
242
+ "identifier": "UAT-320"
261
243
  },
262
244
  "relations": {
263
245
  "nodes": []
@@ -4,12 +4,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
4
  // Mocks
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- const { mockReadFileSync } = vi.hoisted(() => ({
7
+ const { mockReadFileSync, mockWriteFileSync, mockMkdirSync, mockExistsSync } = vi.hoisted(() => ({
8
8
  mockReadFileSync: vi.fn(),
9
+ mockWriteFileSync: vi.fn(),
10
+ mockMkdirSync: vi.fn(),
11
+ mockExistsSync: vi.fn().mockReturnValue(true),
9
12
  }));
10
13
 
11
14
  vi.mock("node:fs", () => ({
12
15
  readFileSync: mockReadFileSync,
16
+ writeFileSync: mockWriteFileSync,
17
+ mkdirSync: mockMkdirSync,
18
+ existsSync: mockExistsSync,
13
19
  }));
14
20
 
15
21
  // ---------------------------------------------------------------------------
@@ -21,6 +27,9 @@ import {
21
27
  buildMentionPattern,
22
28
  resolveAgentFromAlias,
23
29
  resolveDefaultAgent,
30
+ createAgentProfilesFile,
31
+ validateProfiles,
32
+ PROFILES_PATH,
24
33
  _resetProfilesCacheForTesting,
25
34
  type AgentProfile,
26
35
  } from "./shared-profiles.js";
@@ -61,6 +70,7 @@ beforeEach(() => {
61
70
  vi.clearAllMocks();
62
71
  _resetProfilesCacheForTesting();
63
72
  mockReadFileSync.mockReturnValue(PROFILES_JSON);
73
+ mockExistsSync.mockReturnValue(true);
64
74
  });
65
75
 
66
76
  afterEach(() => {
@@ -260,3 +270,115 @@ describe("resolveDefaultAgent", () => {
260
270
  expect(result).toBe("mal");
261
271
  });
262
272
  });
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // createAgentProfilesFile
276
+ // ---------------------------------------------------------------------------
277
+
278
+ describe("createAgentProfilesFile", () => {
279
+ it("writes correct JSON structure to PROFILES_PATH", () => {
280
+ createAgentProfilesFile({
281
+ agentId: "bobbin",
282
+ label: "Bobbin",
283
+ mentionAliases: ["bobbin", "bob"],
284
+ });
285
+
286
+ expect(mockMkdirSync).toHaveBeenCalledTimes(1);
287
+ expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
288
+
289
+ const [path, content] = mockWriteFileSync.mock.calls[0];
290
+ expect(path).toBe(PROFILES_PATH);
291
+
292
+ const parsed = JSON.parse(content);
293
+ expect(parsed.agents.bobbin).toEqual({
294
+ label: "Bobbin",
295
+ mission: "AI assistant for Linear issues",
296
+ isDefault: true,
297
+ mentionAliases: ["bobbin", "bob"],
298
+ });
299
+ });
300
+
301
+ it("uses custom mission when provided", () => {
302
+ createAgentProfilesFile({
303
+ agentId: "claw",
304
+ label: "The Claw",
305
+ mentionAliases: ["claw"],
306
+ mission: "Code review specialist",
307
+ });
308
+
309
+ const [, content] = mockWriteFileSync.mock.calls[0];
310
+ const parsed = JSON.parse(content);
311
+ expect(parsed.agents.claw.mission).toBe("Code review specialist");
312
+ });
313
+
314
+ it("creates parent directory recursively", () => {
315
+ createAgentProfilesFile({
316
+ agentId: "test",
317
+ label: "Test",
318
+ mentionAliases: ["test"],
319
+ });
320
+
321
+ expect(mockMkdirSync).toHaveBeenCalledWith(
322
+ expect.any(String),
323
+ { recursive: true },
324
+ );
325
+ });
326
+
327
+ it("busts the profile cache", () => {
328
+ // Load to populate cache
329
+ loadAgentProfiles();
330
+ expect(mockReadFileSync).toHaveBeenCalledTimes(1);
331
+
332
+ // Create a new profile — should bust cache
333
+ createAgentProfilesFile({
334
+ agentId: "fresh",
335
+ label: "Fresh",
336
+ mentionAliases: ["fresh"],
337
+ });
338
+
339
+ // Next load should re-read from disk (cache was busted)
340
+ loadAgentProfiles();
341
+ expect(mockReadFileSync).toHaveBeenCalledTimes(2);
342
+ });
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // validateProfiles
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe("validateProfiles", () => {
350
+ it("returns error when file is missing", () => {
351
+ mockExistsSync.mockReturnValue(false);
352
+
353
+ const result = validateProfiles();
354
+ expect(result).not.toBeNull();
355
+ expect(result).toContain("not found");
356
+ expect(result).toContain("openclaw openclaw-linear setup");
357
+ });
358
+
359
+ it("returns null when file is valid with agents", () => {
360
+ mockExistsSync.mockReturnValue(true);
361
+ mockReadFileSync.mockReturnValue(PROFILES_JSON);
362
+
363
+ const result = validateProfiles();
364
+ expect(result).toBeNull();
365
+ });
366
+
367
+ it("returns error when JSON is invalid", () => {
368
+ mockExistsSync.mockReturnValue(true);
369
+ mockReadFileSync.mockImplementation(() => { throw new SyntaxError("Unexpected token"); });
370
+
371
+ const result = validateProfiles();
372
+ expect(result).not.toBeNull();
373
+ expect(result).toContain("could not be parsed");
374
+ });
375
+
376
+ it("returns error when agents object is empty", () => {
377
+ mockExistsSync.mockReturnValue(true);
378
+ mockReadFileSync.mockReturnValue(JSON.stringify({ agents: {} }));
379
+
380
+ const result = validateProfiles();
381
+ expect(result).not.toBeNull();
382
+ expect(result).toContain("no agents configured");
383
+ });
384
+ });
@@ -42,9 +42,16 @@ let _affinityTtlMs = 30 * 60_000; // 30 minutes default
42
42
  /**
43
43
  * Register the active session for an issue. Idempotent — calling again
44
44
  * for the same issue just updates the session.
45
+ *
46
+ * Also eagerly records agent affinity so that follow-up webhooks arriving
47
+ * during or after the run resolve to the correct agent — even if the
48
+ * gateway restarts before clearActiveSession is called.
45
49
  */
46
50
  export function setActiveSession(session: ActiveSession): void {
47
51
  sessions.set(session.issueId, session);
52
+ if (session.agentId) {
53
+ recordIssueAffinity(session.issueId, session.agentId);
54
+ }
48
55
  }
49
56
 
50
57
  /**
@@ -484,10 +484,10 @@ export async function handleLinearWebhook(
484
484
  activeRuns.add(issue.id);
485
485
  void (async () => {
486
486
  const profiles = loadAgentProfiles();
487
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
488
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
487
+ const label = profiles[agentId]?.label ?? agentId;
489
488
 
490
489
  // Register active session for tool resolution (code_run, etc.)
490
+ // Also eagerly records affinity so follow-ups route to the same agent.
491
491
  setActiveSession({
492
492
  agentSessionId: session.id,
493
493
  issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
@@ -531,7 +531,7 @@ export async function handleLinearWebhook(
531
531
  }).then(() => true).catch(() => false);
532
532
 
533
533
  if (!emitted) {
534
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
534
+ const avatarUrl = profiles[agentId]?.avatarUrl;
535
535
  const agentOpts = avatarUrl
536
536
  ? { createAsUser: label, displayIconUrl: avatarUrl }
537
537
  : undefined;
@@ -648,8 +648,7 @@ export async function handleLinearWebhook(
648
648
  activeRuns.add(issue.id);
649
649
  void (async () => {
650
650
  const profiles = loadAgentProfiles();
651
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
652
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
651
+ const label = profiles[agentId]?.label ?? agentId;
653
652
 
654
653
  // Fetch full issue details for context
655
654
  let enrichedIssue: any = issue;
@@ -764,7 +763,7 @@ export async function handleLinearWebhook(
764
763
  }).then(() => true).catch(() => false);
765
764
 
766
765
  if (!emitted) {
767
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
766
+ const avatarUrl = profiles[agentId]?.avatarUrl;
768
767
  const agentOpts = avatarUrl
769
768
  ? { createAsUser: label, displayIconUrl: avatarUrl }
770
769
  : undefined;
@@ -1150,9 +1149,8 @@ export async function handleLinearWebhook(
1150
1149
  // Dispatch triage (non-blocking)
1151
1150
  void (async () => {
1152
1151
  const profiles = loadAgentProfiles();
1153
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
1154
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
1155
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
1152
+ const label = profiles[agentId]?.label ?? agentId;
1153
+ const avatarUrl = profiles[agentId]?.avatarUrl;
1156
1154
  let agentSessionId: string | null = null;
1157
1155
 
1158
1156
  try {