@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.
- package/package.json +1 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +254 -0
- package/src/__test__/smoke-linear-api.test.ts +207 -2
- package/src/agent/watchdog.ts +1 -0
- package/src/infra/doctor.test.ts +311 -1
- package/src/pipeline/sub-issue-decomposition.test.ts +388 -0
- package/src/pipeline/webhook.test.ts +196 -5
- package/src/pipeline/webhook.ts +6 -2
|
@@ -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: "/
|
|
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
|
-
|
|
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: "/
|
|
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: "/
|
|
4280
|
-
{ name: "spa", path: "/
|
|
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
|
+
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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}`);
|