@calltelemetry/openclaw-linear 0.7.0 → 0.8.0
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/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E planning pipeline tests.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
|
|
5
|
+
* → runPlanAudit → onApproved → DAG dispatch cascade.
|
|
6
|
+
*
|
|
7
|
+
* Mocked: runAgent, LinearAgentApi. Real: planning-state.ts, planner.ts,
|
|
8
|
+
* planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mocks — external boundaries only
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const { runAgentMock } = vi.hoisted(() => ({
|
|
17
|
+
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../agent/agent.js", () => ({
|
|
21
|
+
runAgent: runAgentMock,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../api/linear-api.js", () => ({}));
|
|
25
|
+
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
26
|
+
vi.mock("../infra/observability.js", () => ({
|
|
27
|
+
emitDiagnostic: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Imports (AFTER mocks)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
35
|
+
import { readPlanningState, type PlanningSession } from "./planning-state.js";
|
|
36
|
+
import { writeProjectDispatch, readProjectDispatch, onProjectIssueCompleted, onProjectIssueStuck, type ProjectDispatchState } from "./dag-dispatch.js";
|
|
37
|
+
import { createMockLinearApi, tmpStatePath } from "../__test__/helpers.js";
|
|
38
|
+
import { makeProjectIssue } from "../__test__/fixtures/linear-responses.js";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function createCtx(configPath: string, overrides?: Record<string, unknown>) {
|
|
45
|
+
const linearApi = createMockLinearApi();
|
|
46
|
+
return {
|
|
47
|
+
ctx: {
|
|
48
|
+
api: {
|
|
49
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
50
|
+
pluginConfig: { planningStatePath: configPath, ...overrides },
|
|
51
|
+
} as any,
|
|
52
|
+
linearApi: linearApi as any,
|
|
53
|
+
pluginConfig: { planningStatePath: configPath, ...overrides },
|
|
54
|
+
},
|
|
55
|
+
linearApi,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createSession(configPath: string, overrides?: Partial<PlanningSession>): PlanningSession {
|
|
60
|
+
return {
|
|
61
|
+
projectId: "proj-1",
|
|
62
|
+
projectName: "Test Project",
|
|
63
|
+
rootIssueId: "issue-1",
|
|
64
|
+
rootIdentifier: "PROJ-1",
|
|
65
|
+
teamId: "team-1",
|
|
66
|
+
status: "interviewing",
|
|
67
|
+
startedAt: new Date().toISOString(),
|
|
68
|
+
turnCount: 0,
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build a set of project issues that will pass auditPlan (description ≥ 50 chars, estimate, priority). */
|
|
74
|
+
function makePassingIssues() {
|
|
75
|
+
return [
|
|
76
|
+
makeProjectIssue("PROJ-2", {
|
|
77
|
+
title: "Implement search API",
|
|
78
|
+
description: "Build the search API endpoint with filtering and pagination support for the frontend.",
|
|
79
|
+
estimate: 3,
|
|
80
|
+
priority: 2,
|
|
81
|
+
labels: ["Epic"],
|
|
82
|
+
}),
|
|
83
|
+
makeProjectIssue("PROJ-3", {
|
|
84
|
+
title: "Build search results page",
|
|
85
|
+
description: "Create a search results page component that displays results from the search API endpoint.",
|
|
86
|
+
estimate: 2,
|
|
87
|
+
priority: 2,
|
|
88
|
+
parentIdentifier: "PROJ-2",
|
|
89
|
+
}),
|
|
90
|
+
makeProjectIssue("PROJ-4", {
|
|
91
|
+
title: "Add search autocomplete",
|
|
92
|
+
description: "Implement autocomplete suggestions in the search input using the search API typeahead endpoint.",
|
|
93
|
+
estimate: 1,
|
|
94
|
+
priority: 3,
|
|
95
|
+
parentIdentifier: "PROJ-2",
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Tests
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe("E2E planning pipeline", () => {
|
|
105
|
+
let configPath: string;
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
configPath = tmpStatePath("claw-e2e-plan-");
|
|
110
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// =========================================================================
|
|
114
|
+
// Test 1: Full lifecycle — initiate → interview → approve
|
|
115
|
+
// =========================================================================
|
|
116
|
+
it("full lifecycle: initiate → interview turns → finalize → approved", async () => {
|
|
117
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
118
|
+
|
|
119
|
+
const rootIssue = { id: "issue-1", identifier: "PROJ-1", title: "Root Issue", team: { id: "team-1" } };
|
|
120
|
+
|
|
121
|
+
// Mock getProject and getTeamStates for initiation
|
|
122
|
+
linearApi.getProject.mockResolvedValue({
|
|
123
|
+
id: "proj-1",
|
|
124
|
+
name: "Test Project",
|
|
125
|
+
teams: { nodes: [{ id: "team-1", name: "Team" }] },
|
|
126
|
+
});
|
|
127
|
+
linearApi.getTeamStates.mockResolvedValue([
|
|
128
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// Step 1: Initiate planning session
|
|
132
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
133
|
+
|
|
134
|
+
// Verify welcome comment posted
|
|
135
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
136
|
+
"issue-1",
|
|
137
|
+
expect.stringContaining("planning mode"),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Verify session registered
|
|
141
|
+
let state = await readPlanningState(configPath);
|
|
142
|
+
expect(state.sessions["proj-1"]).toBeDefined();
|
|
143
|
+
expect(state.sessions["proj-1"].status).toBe("interviewing");
|
|
144
|
+
expect(state.sessions["proj-1"].turnCount).toBe(0);
|
|
145
|
+
|
|
146
|
+
// Step 2: Interview turn 1
|
|
147
|
+
const session = createSession(configPath);
|
|
148
|
+
|
|
149
|
+
// Mock getProjectIssues and getIssueDetails for interview
|
|
150
|
+
linearApi.getProjectIssues.mockResolvedValue([]);
|
|
151
|
+
linearApi.getIssueDetails.mockResolvedValue({
|
|
152
|
+
id: "issue-1",
|
|
153
|
+
identifier: "PROJ-1",
|
|
154
|
+
title: "Root Issue",
|
|
155
|
+
comments: { nodes: [] },
|
|
156
|
+
project: { id: "proj-1" },
|
|
157
|
+
team: { id: "team-1" },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await handlePlannerTurn(ctx, session, {
|
|
161
|
+
issueId: "issue-1",
|
|
162
|
+
commentBody: "Build a search API and results page",
|
|
163
|
+
commentorName: "User",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Verify runAgent called for interview
|
|
167
|
+
expect(runAgentMock).toHaveBeenCalledTimes(1);
|
|
168
|
+
|
|
169
|
+
// Verify agent response posted as comment
|
|
170
|
+
expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
|
|
171
|
+
|
|
172
|
+
// Verify turnCount incremented
|
|
173
|
+
state = await readPlanningState(configPath);
|
|
174
|
+
expect(state.sessions["proj-1"].turnCount).toBe(1);
|
|
175
|
+
|
|
176
|
+
// Step 3: Interview turn 2
|
|
177
|
+
vi.clearAllMocks();
|
|
178
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Great, plan updated." });
|
|
179
|
+
|
|
180
|
+
const session2 = { ...session, turnCount: 1 };
|
|
181
|
+
await handlePlannerTurn(ctx, session2, {
|
|
182
|
+
issueId: "issue-1",
|
|
183
|
+
commentBody: "Add autocomplete too",
|
|
184
|
+
commentorName: "User",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(runAgentMock).toHaveBeenCalledTimes(1);
|
|
188
|
+
state = await readPlanningState(configPath);
|
|
189
|
+
expect(state.sessions["proj-1"].turnCount).toBe(2);
|
|
190
|
+
|
|
191
|
+
// Step 4: Finalize — with passing issues in the project
|
|
192
|
+
vi.clearAllMocks();
|
|
193
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
194
|
+
|
|
195
|
+
const session3 = { ...session, turnCount: 2 };
|
|
196
|
+
const onApproved = vi.fn();
|
|
197
|
+
|
|
198
|
+
await handlePlannerTurn(ctx, session3, {
|
|
199
|
+
issueId: "issue-1",
|
|
200
|
+
commentBody: "finalize plan",
|
|
201
|
+
commentorName: "User",
|
|
202
|
+
}, { onApproved });
|
|
203
|
+
|
|
204
|
+
// Verify "Plan Approved" comment
|
|
205
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
206
|
+
"issue-1",
|
|
207
|
+
expect.stringContaining("Plan Approved"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Verify session ended as approved
|
|
211
|
+
state = await readPlanningState(configPath);
|
|
212
|
+
expect(state.sessions["proj-1"].status).toBe("approved");
|
|
213
|
+
|
|
214
|
+
// Verify onApproved callback fired
|
|
215
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// =========================================================================
|
|
219
|
+
// Test 2: Audit fail → re-plan → pass
|
|
220
|
+
// =========================================================================
|
|
221
|
+
it("audit fail → fix issues → re-finalize → approved", async () => {
|
|
222
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
223
|
+
const session = createSession(configPath);
|
|
224
|
+
|
|
225
|
+
// Register session so updatePlanningSession/endPlanningSession can find it
|
|
226
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
227
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
228
|
+
|
|
229
|
+
linearApi.getIssueDetails.mockResolvedValue({
|
|
230
|
+
id: "issue-1",
|
|
231
|
+
identifier: "PROJ-1",
|
|
232
|
+
title: "Root Issue",
|
|
233
|
+
comments: { nodes: [] },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// First finalize — with issues that fail audit (missing descriptions/estimates)
|
|
237
|
+
linearApi.getProjectIssues.mockResolvedValue([
|
|
238
|
+
makeProjectIssue("PROJ-2", {
|
|
239
|
+
title: "Bad issue",
|
|
240
|
+
description: "short", // <50 chars → audit fails
|
|
241
|
+
}),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
await runPlanAudit(ctx, session);
|
|
245
|
+
|
|
246
|
+
// Verify "Plan Audit Failed" comment
|
|
247
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
248
|
+
"issue-1",
|
|
249
|
+
expect.stringContaining("Plan Audit Failed"),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Session should still be interviewing (NOT approved)
|
|
253
|
+
let state = await readPlanningState(configPath);
|
|
254
|
+
expect(state.sessions["proj-1"].status).toBe("interviewing");
|
|
255
|
+
|
|
256
|
+
// Second finalize — with proper issues
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
259
|
+
|
|
260
|
+
const onApproved = vi.fn();
|
|
261
|
+
await runPlanAudit(ctx, session, { onApproved });
|
|
262
|
+
|
|
263
|
+
// Now should be approved
|
|
264
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
265
|
+
"issue-1",
|
|
266
|
+
expect.stringContaining("Plan Approved"),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
state = await readPlanningState(configPath);
|
|
270
|
+
expect(state.sessions["proj-1"].status).toBe("approved");
|
|
271
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// =========================================================================
|
|
275
|
+
// Test 3: Abandon
|
|
276
|
+
// =========================================================================
|
|
277
|
+
it("abandon: cancel planning ends session", async () => {
|
|
278
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
279
|
+
const session = createSession(configPath);
|
|
280
|
+
|
|
281
|
+
// Register session so endPlanningSession can find it
|
|
282
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
283
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
284
|
+
|
|
285
|
+
await handlePlannerTurn(ctx, session, {
|
|
286
|
+
issueId: "issue-1",
|
|
287
|
+
commentBody: "cancel planning",
|
|
288
|
+
commentorName: "User",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Verify abandonment comment
|
|
292
|
+
expect(linearApi.createComment).toHaveBeenCalledWith(
|
|
293
|
+
"issue-1",
|
|
294
|
+
expect.stringContaining("Planning mode ended"),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Session ended as abandoned
|
|
298
|
+
const state = await readPlanningState(configPath);
|
|
299
|
+
expect(state.sessions["proj-1"].status).toBe("abandoned");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// =========================================================================
|
|
303
|
+
// Test 4: onApproved fires
|
|
304
|
+
// =========================================================================
|
|
305
|
+
it("onApproved callback fires with projectId on approval", async () => {
|
|
306
|
+
const { ctx, linearApi } = createCtx(configPath);
|
|
307
|
+
const session = createSession(configPath);
|
|
308
|
+
|
|
309
|
+
// Register session so endPlanningSession can update it
|
|
310
|
+
const { registerPlanningSession } = await import("./planning-state.js");
|
|
311
|
+
await registerPlanningSession("proj-1", session, configPath);
|
|
312
|
+
|
|
313
|
+
linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
|
|
314
|
+
|
|
315
|
+
const onApproved = vi.fn();
|
|
316
|
+
await runPlanAudit(ctx, session, { onApproved });
|
|
317
|
+
|
|
318
|
+
expect(onApproved).toHaveBeenCalledTimes(1);
|
|
319
|
+
expect(onApproved).toHaveBeenCalledWith("proj-1");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// =========================================================================
|
|
323
|
+
// Test 5: DAG cascade — full chain
|
|
324
|
+
// =========================================================================
|
|
325
|
+
it("DAG cascade: complete issues in sequence, project completes", async () => {
|
|
326
|
+
const dagConfigPath = tmpStatePath("claw-e2e-dag-");
|
|
327
|
+
const notifyCalls: Array<[string, unknown]> = [];
|
|
328
|
+
const hookCtx = {
|
|
329
|
+
api: {
|
|
330
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
331
|
+
} as any,
|
|
332
|
+
linearApi: {} as any,
|
|
333
|
+
notify: vi.fn(async (kind: string, payload: unknown) => {
|
|
334
|
+
notifyCalls.push([kind, payload]);
|
|
335
|
+
}),
|
|
336
|
+
pluginConfig: {},
|
|
337
|
+
configPath: dagConfigPath,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Set up 3-issue project: A → B → C
|
|
341
|
+
const projectDispatch: ProjectDispatchState = {
|
|
342
|
+
projectId: "proj-1",
|
|
343
|
+
projectName: "Test Project",
|
|
344
|
+
rootIdentifier: "PROJ-1",
|
|
345
|
+
status: "dispatching",
|
|
346
|
+
startedAt: new Date().toISOString(),
|
|
347
|
+
maxConcurrent: 3,
|
|
348
|
+
issues: {
|
|
349
|
+
"PROJ-A": {
|
|
350
|
+
identifier: "PROJ-A",
|
|
351
|
+
issueId: "id-a",
|
|
352
|
+
dependsOn: [],
|
|
353
|
+
unblocks: ["PROJ-B"],
|
|
354
|
+
dispatchStatus: "dispatched",
|
|
355
|
+
},
|
|
356
|
+
"PROJ-B": {
|
|
357
|
+
identifier: "PROJ-B",
|
|
358
|
+
issueId: "id-b",
|
|
359
|
+
dependsOn: ["PROJ-A"],
|
|
360
|
+
unblocks: ["PROJ-C"],
|
|
361
|
+
dispatchStatus: "pending",
|
|
362
|
+
},
|
|
363
|
+
"PROJ-C": {
|
|
364
|
+
identifier: "PROJ-C",
|
|
365
|
+
issueId: "id-c",
|
|
366
|
+
dependsOn: ["PROJ-B"],
|
|
367
|
+
unblocks: [],
|
|
368
|
+
dispatchStatus: "pending",
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
await writeProjectDispatch(projectDispatch, dagConfigPath);
|
|
373
|
+
|
|
374
|
+
// Complete A → B becomes ready
|
|
375
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-A");
|
|
376
|
+
|
|
377
|
+
let state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
378
|
+
expect(state!.issues["PROJ-A"].dispatchStatus).toBe("done");
|
|
379
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("dispatched");
|
|
380
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("pending");
|
|
381
|
+
expect(state!.status).toBe("dispatching");
|
|
382
|
+
|
|
383
|
+
// Complete B → C becomes ready
|
|
384
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-B");
|
|
385
|
+
|
|
386
|
+
state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
387
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("done");
|
|
388
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("dispatched");
|
|
389
|
+
expect(state!.status).toBe("dispatching");
|
|
390
|
+
|
|
391
|
+
// Complete C → project complete
|
|
392
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-C");
|
|
393
|
+
|
|
394
|
+
state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
395
|
+
expect(state!.issues["PROJ-C"].dispatchStatus).toBe("done");
|
|
396
|
+
expect(state!.status).toBe("completed");
|
|
397
|
+
|
|
398
|
+
// Verify notifications
|
|
399
|
+
const progressNotifications = notifyCalls.filter(([k]) => k === "project_progress");
|
|
400
|
+
const completeNotifications = notifyCalls.filter(([k]) => k === "project_complete");
|
|
401
|
+
expect(progressNotifications.length).toBe(2); // after A, after B
|
|
402
|
+
expect(completeNotifications.length).toBe(1); // after C
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// =========================================================================
|
|
406
|
+
// Test 6: DAG stuck
|
|
407
|
+
// =========================================================================
|
|
408
|
+
it("DAG stuck: stuck issue blocks dependent, project stuck", async () => {
|
|
409
|
+
const dagConfigPath = tmpStatePath("claw-e2e-dag-stuck-");
|
|
410
|
+
const hookCtx = {
|
|
411
|
+
api: {
|
|
412
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
413
|
+
} as any,
|
|
414
|
+
linearApi: {} as any,
|
|
415
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
416
|
+
pluginConfig: {},
|
|
417
|
+
configPath: dagConfigPath,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// 2-issue project: A → B
|
|
421
|
+
const projectDispatch: ProjectDispatchState = {
|
|
422
|
+
projectId: "proj-1",
|
|
423
|
+
projectName: "Test Project",
|
|
424
|
+
rootIdentifier: "PROJ-1",
|
|
425
|
+
status: "dispatching",
|
|
426
|
+
startedAt: new Date().toISOString(),
|
|
427
|
+
maxConcurrent: 3,
|
|
428
|
+
issues: {
|
|
429
|
+
"PROJ-A": {
|
|
430
|
+
identifier: "PROJ-A",
|
|
431
|
+
issueId: "id-a",
|
|
432
|
+
dependsOn: [],
|
|
433
|
+
unblocks: ["PROJ-B"],
|
|
434
|
+
dispatchStatus: "dispatched",
|
|
435
|
+
},
|
|
436
|
+
"PROJ-B": {
|
|
437
|
+
identifier: "PROJ-B",
|
|
438
|
+
issueId: "id-b",
|
|
439
|
+
dependsOn: ["PROJ-A"],
|
|
440
|
+
unblocks: [],
|
|
441
|
+
dispatchStatus: "pending",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
await writeProjectDispatch(projectDispatch, dagConfigPath);
|
|
446
|
+
|
|
447
|
+
// A gets stuck
|
|
448
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-A");
|
|
449
|
+
|
|
450
|
+
const state = await readProjectDispatch("proj-1", dagConfigPath);
|
|
451
|
+
expect(state!.issues["PROJ-A"].dispatchStatus).toBe("stuck");
|
|
452
|
+
expect(state!.issues["PROJ-B"].dispatchStatus).toBe("pending"); // still blocked
|
|
453
|
+
expect(state!.status).toBe("stuck"); // project stuck — no progress possible
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -223,4 +223,73 @@ describe("loadPrompts", () => {
|
|
|
223
223
|
expect(first).not.toBe(second);
|
|
224
224
|
expect(first).toEqual(second);
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
it("merges global overlay with defaults (section-level shallow merge)", () => {
|
|
228
|
+
// Use promptsPath in pluginConfig to load custom YAML from a temp file
|
|
229
|
+
const { writeFileSync, mkdtempSync } = require("node:fs");
|
|
230
|
+
const { join } = require("node:path");
|
|
231
|
+
const { tmpdir } = require("node:os");
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-prompt-"));
|
|
233
|
+
const yamlPath = join(dir, "prompts.yaml");
|
|
234
|
+
writeFileSync(yamlPath, "worker:\n system: custom worker system\n");
|
|
235
|
+
|
|
236
|
+
clearPromptCache();
|
|
237
|
+
const prompts = loadPrompts({ promptsPath: yamlPath });
|
|
238
|
+
// Worker system should be overridden
|
|
239
|
+
expect(prompts.worker.system).toBe("custom worker system");
|
|
240
|
+
// Worker task should still be default
|
|
241
|
+
expect(prompts.worker.task).toContain("{{identifier}}");
|
|
242
|
+
// Audit should be completely default
|
|
243
|
+
expect(prompts.audit.system).toContain("auditor");
|
|
244
|
+
clearPromptCache();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("merges per-project overlay on top of global (three layers)", () => {
|
|
248
|
+
const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
|
|
249
|
+
const { join } = require("node:path");
|
|
250
|
+
const { tmpdir } = require("node:os");
|
|
251
|
+
|
|
252
|
+
// Layer 2: global override via promptsPath
|
|
253
|
+
const globalDir = mkdtempSync(join(tmpdir(), "claw-prompt-global-"));
|
|
254
|
+
const globalYaml = join(globalDir, "prompts.yaml");
|
|
255
|
+
writeFileSync(globalYaml, "worker:\n system: global system\n");
|
|
256
|
+
|
|
257
|
+
// Layer 3: per-project override in worktree/.claw/prompts.yaml
|
|
258
|
+
const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-wt-"));
|
|
259
|
+
mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
|
|
260
|
+
writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "audit:\n system: project auditor\n");
|
|
261
|
+
|
|
262
|
+
clearPromptCache();
|
|
263
|
+
const prompts = loadPrompts({ promptsPath: globalYaml }, worktreeDir);
|
|
264
|
+
// Layer 2: global override
|
|
265
|
+
expect(prompts.worker.system).toBe("global system");
|
|
266
|
+
// Layer 3: per-project override
|
|
267
|
+
expect(prompts.audit.system).toBe("project auditor");
|
|
268
|
+
// Layer 1: defaults retained where not overridden
|
|
269
|
+
expect(prompts.rework.addendum).toContain("PREVIOUS AUDIT FAILED");
|
|
270
|
+
clearPromptCache();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("clearPromptCache clears both global and project caches", () => {
|
|
274
|
+
const { writeFileSync, mkdtempSync, mkdirSync } = require("node:fs");
|
|
275
|
+
const { join } = require("node:path");
|
|
276
|
+
const { tmpdir } = require("node:os");
|
|
277
|
+
|
|
278
|
+
// Per-project YAML only (no global — uses defaults for global)
|
|
279
|
+
const worktreeDir = mkdtempSync(join(tmpdir(), "claw-prompt-cache-"));
|
|
280
|
+
mkdirSync(join(worktreeDir, ".claw"), { recursive: true });
|
|
281
|
+
writeFileSync(join(worktreeDir, ".claw", "prompts.yaml"), "worker:\n system: cached project\n");
|
|
282
|
+
|
|
283
|
+
clearPromptCache();
|
|
284
|
+
const first = loadPrompts(undefined, worktreeDir);
|
|
285
|
+
expect(first.worker.system).toBe("cached project");
|
|
286
|
+
// Same ref from cache
|
|
287
|
+
expect(loadPrompts(undefined, worktreeDir)).toBe(first);
|
|
288
|
+
// Clear both caches
|
|
289
|
+
clearPromptCache();
|
|
290
|
+
const second = loadPrompts(undefined, worktreeDir);
|
|
291
|
+
expect(second).not.toBe(first);
|
|
292
|
+
expect(second.worker.system).toBe("cached project");
|
|
293
|
+
clearPromptCache();
|
|
294
|
+
});
|
|
226
295
|
});
|