@calltelemetry/openclaw-linear 0.8.0 → 0.8.1

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.
@@ -2,10 +2,10 @@
2
2
  * E2E planning pipeline tests.
3
3
  *
4
4
  * Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
5
- * → runPlanAudit → onApproved → DAG dispatch cascade.
5
+ * → runPlanAudit → plan_review(webhook handles approval) → DAG dispatch cascade.
6
6
  *
7
- * Mocked: runAgent, LinearAgentApi. Real: planning-state.ts, planner.ts,
8
- * planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
7
+ * Mocked: runAgent, LinearAgentApi, CLI tool runners. Real: planning-state.ts,
8
+ * planner.ts, planner-tools.ts (auditPlan, buildPlanSnapshot), dag-dispatch.ts.
9
9
  */
10
10
  import { describe, it, expect, beforeEach, vi } from "vitest";
11
11
 
@@ -27,6 +27,17 @@ vi.mock("../infra/observability.js", () => ({
27
27
  emitDiagnostic: vi.fn(),
28
28
  }));
29
29
 
30
+ // Mock CLI tool runners for cross-model review
31
+ vi.mock("../tools/claude-tool.js", () => ({
32
+ runClaude: vi.fn().mockResolvedValue({ success: true, output: "Claude review: looks good" }),
33
+ }));
34
+ vi.mock("../tools/codex-tool.js", () => ({
35
+ runCodex: vi.fn().mockResolvedValue({ success: true, output: "Codex review: approved" }),
36
+ }));
37
+ vi.mock("../tools/gemini-tool.js", () => ({
38
+ runGemini: vi.fn().mockResolvedValue({ success: true, output: "Gemini review: no issues" }),
39
+ }));
40
+
30
41
  // ---------------------------------------------------------------------------
31
42
  // Imports (AFTER mocks)
32
43
  // ---------------------------------------------------------------------------
@@ -75,21 +86,21 @@ function makePassingIssues() {
75
86
  return [
76
87
  makeProjectIssue("PROJ-2", {
77
88
  title: "Implement search API",
78
- description: "Build the search API endpoint with filtering and pagination support for the frontend.",
89
+ description: "As a user, I want a search API so that I can find content. Given I send a query, When results exist, Then they are returned with pagination.",
79
90
  estimate: 3,
80
91
  priority: 2,
81
92
  labels: ["Epic"],
82
93
  }),
83
94
  makeProjectIssue("PROJ-3", {
84
95
  title: "Build search results page",
85
- description: "Create a search results page component that displays results from the search API endpoint.",
96
+ description: "As a user, I want to see search results in a page. Given I perform a search, When results load, Then I see a paginated list of matching items.",
86
97
  estimate: 2,
87
98
  priority: 2,
88
99
  parentIdentifier: "PROJ-2",
89
100
  }),
90
101
  makeProjectIssue("PROJ-4", {
91
102
  title: "Add search autocomplete",
92
- description: "Implement autocomplete suggestions in the search input using the search API typeahead endpoint.",
103
+ description: "As a user, I want autocomplete suggestions. Given I type in the search box, When 3+ characters entered, Then suggestions appear from the typeahead API.",
93
104
  estimate: 1,
94
105
  priority: 3,
95
106
  parentIdentifier: "PROJ-2",
@@ -111,9 +122,9 @@ describe("E2E planning pipeline", () => {
111
122
  });
112
123
 
113
124
  // =========================================================================
114
- // Test 1: Full lifecycle — initiate → interview → approve
125
+ // Test 1: Full lifecycle — initiate → interview → audit → plan_review
115
126
  // =========================================================================
116
- it("full lifecycle: initiate → interview turns → finalizeapproved", async () => {
127
+ it("full lifecycle: initiate → interview turns → auditplan_review", async () => {
117
128
  const { ctx, linearApi } = createCtx(configPath);
118
129
 
119
130
  const rootIssue = { id: "issue-1", identifier: "PROJ-1", title: "Root Issue", team: { id: "team-1" } };
@@ -188,37 +199,34 @@ describe("E2E planning pipeline", () => {
188
199
  state = await readPlanningState(configPath);
189
200
  expect(state.sessions["proj-1"].turnCount).toBe(2);
190
201
 
191
- // Step 4: Finalize — with passing issues in the project
202
+ // Step 4: Audit — with passing issues
203
+ // Note: finalize intent detection now happens in webhook.ts, not handlePlannerTurn.
204
+ // We call runPlanAudit directly (as the webhook would after intent classification).
192
205
  vi.clearAllMocks();
206
+ runAgentMock.mockResolvedValue({ success: true, output: "Review complete." });
193
207
  linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
194
208
 
195
209
  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 });
210
+ await runPlanAudit(ctx, session3);
203
211
 
204
- // Verify "Plan Approved" comment
212
+ // Verify "Plan Passed Checks" comment (not "Approved" — that comes from webhook)
205
213
  expect(linearApi.createComment).toHaveBeenCalledWith(
206
214
  "issue-1",
207
- expect.stringContaining("Plan Approved"),
215
+ expect.stringContaining("Plan Passed Checks"),
208
216
  );
209
217
 
210
- // Verify session ended as approved
218
+ // Session transitions to plan_review (awaiting user's "approve plan")
211
219
  state = await readPlanningState(configPath);
212
- expect(state.sessions["proj-1"].status).toBe("approved");
220
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
213
221
 
214
- // Verify onApproved callback fired
215
- expect(onApproved).toHaveBeenCalledWith("proj-1");
222
+ // Cross-model review ran (runAgent called for review prompt)
223
+ expect(runAgentMock).toHaveBeenCalled();
216
224
  });
217
225
 
218
226
  // =========================================================================
219
- // Test 2: Audit fail → re-planpass
227
+ // Test 2: Audit fail → fix issues → re-auditplan_review
220
228
  // =========================================================================
221
- it("audit fail → fix issues → re-finalizeapproved", async () => {
229
+ it("audit fail → fix issues → re-auditplan_review", async () => {
222
230
  const { ctx, linearApi } = createCtx(configPath);
223
231
  const session = createSession(configPath);
224
232
 
@@ -233,7 +241,7 @@ describe("E2E planning pipeline", () => {
233
241
  comments: { nodes: [] },
234
242
  });
235
243
 
236
- // First finalize — with issues that fail audit (missing descriptions/estimates)
244
+ // First audit — with issues that fail (missing descriptions/estimates)
237
245
  linearApi.getProjectIssues.mockResolvedValue([
238
246
  makeProjectIssue("PROJ-2", {
239
247
  title: "Bad issue",
@@ -249,74 +257,89 @@ describe("E2E planning pipeline", () => {
249
257
  expect.stringContaining("Plan Audit Failed"),
250
258
  );
251
259
 
252
- // Session should still be interviewing (NOT approved)
260
+ // Session should still be interviewing (NOT plan_review)
253
261
  let state = await readPlanningState(configPath);
254
262
  expect(state.sessions["proj-1"].status).toBe("interviewing");
255
263
 
256
- // Second finalize — with proper issues
264
+ // Second audit — with proper issues
257
265
  vi.clearAllMocks();
266
+ runAgentMock.mockResolvedValue({ success: true, output: "Review complete." });
258
267
  linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
259
268
 
260
- const onApproved = vi.fn();
261
- await runPlanAudit(ctx, session, { onApproved });
269
+ await runPlanAudit(ctx, session);
262
270
 
263
- // Now should be approved
271
+ // Now should be plan_review (waiting for user approval via webhook)
264
272
  expect(linearApi.createComment).toHaveBeenCalledWith(
265
273
  "issue-1",
266
- expect.stringContaining("Plan Approved"),
274
+ expect.stringContaining("Plan Passed Checks"),
267
275
  );
268
276
 
269
277
  state = await readPlanningState(configPath);
270
- expect(state.sessions["proj-1"].status).toBe("approved");
271
- expect(onApproved).toHaveBeenCalledWith("proj-1");
278
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
272
279
  });
273
280
 
274
281
  // =========================================================================
275
- // Test 3: Abandon
282
+ // Test 3: handlePlannerTurn is pure continue — no intent detection
276
283
  // =========================================================================
277
- it("abandon: cancel planning ends session", async () => {
284
+ it("handlePlannerTurn always runs agent regardless of message content", async () => {
278
285
  const { ctx, linearApi } = createCtx(configPath);
279
286
  const session = createSession(configPath);
280
287
 
281
- // Register session so endPlanningSession can find it
282
288
  const { registerPlanningSession } = await import("./planning-state.js");
283
289
  await registerPlanningSession("proj-1", session, configPath);
284
290
 
291
+ linearApi.getProjectIssues.mockResolvedValue([]);
292
+ linearApi.getIssueDetails.mockResolvedValue({
293
+ id: "issue-1",
294
+ identifier: "PROJ-1",
295
+ title: "Root Issue",
296
+ comments: { nodes: [] },
297
+ });
298
+
299
+ // Even "finalize plan" goes through the agent (intent detection is in webhook)
285
300
  await handlePlannerTurn(ctx, session, {
286
301
  issueId: "issue-1",
287
- commentBody: "cancel planning",
302
+ commentBody: "finalize plan",
288
303
  commentorName: "User",
289
304
  });
290
305
 
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");
306
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
307
+ expect(linearApi.createComment).toHaveBeenCalledWith("issue-1", "Mock planner response");
300
308
  });
301
309
 
302
310
  // =========================================================================
303
- // Test 4: onApproved fires
311
+ // Test 4: Audit with warnings still passes
304
312
  // =========================================================================
305
- it("onApproved callback fires with projectId on approval", async () => {
313
+ it("audit passes with warnings (AC warnings do not block)", async () => {
306
314
  const { ctx, linearApi } = createCtx(configPath);
307
315
  const session = createSession(configPath);
308
316
 
309
- // Register session so endPlanningSession can update it
310
317
  const { registerPlanningSession } = await import("./planning-state.js");
311
318
  await registerPlanningSession("proj-1", session, configPath);
312
319
 
313
- linearApi.getProjectIssues.mockResolvedValue(makePassingIssues());
320
+ // Issues that pass but lack AC markers → warnings
321
+ linearApi.getProjectIssues.mockResolvedValue([
322
+ makeProjectIssue("PROJ-2", {
323
+ title: "Search feature",
324
+ description: "Build the search API endpoint with filtering and pagination support for the frontend application.",
325
+ estimate: 3,
326
+ priority: 2,
327
+ labels: ["Epic"],
328
+ }),
329
+ ]);
314
330
 
315
- const onApproved = vi.fn();
316
- await runPlanAudit(ctx, session, { onApproved });
331
+ runAgentMock.mockResolvedValue({ success: true, output: "Review with warnings." });
317
332
 
318
- expect(onApproved).toHaveBeenCalledTimes(1);
319
- expect(onApproved).toHaveBeenCalledWith("proj-1");
333
+ await runPlanAudit(ctx, session);
334
+
335
+ // Still passes (warnings are not problems)
336
+ expect(linearApi.createComment).toHaveBeenCalledWith(
337
+ "issue-1",
338
+ expect.stringContaining("Plan Passed Checks"),
339
+ );
340
+
341
+ const state = await readPlanningState(configPath);
342
+ expect(state.sessions["proj-1"].status).toBe("plan_review");
320
343
  });
321
344
 
322
345
  // =========================================================================
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const { runAgentMock } = vi.hoisted(() => ({
8
+ runAgentMock: vi.fn().mockResolvedValue({
9
+ success: true,
10
+ output: '{"intent":"general","reasoning":"test"}',
11
+ }),
12
+ }));
13
+
14
+ vi.mock("../agent/agent.js", () => ({
15
+ runAgent: runAgentMock,
16
+ }));
17
+
18
+ vi.mock("../api/linear-api.js", () => ({}));
19
+ vi.mock("openclaw/plugin-sdk", () => ({}));
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Imports (AFTER mocks)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ import { classifyIntent, regexFallback, type IntentContext } from "./intent-classify.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function createApi(overrides?: Record<string, unknown>) {
32
+ return {
33
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
34
+ pluginConfig: overrides ?? {},
35
+ } as any;
36
+ }
37
+
38
+ function createCtx(overrides?: Partial<IntentContext>): IntentContext {
39
+ return {
40
+ commentBody: "hello world",
41
+ issueTitle: "Test Issue",
42
+ issueStatus: "In Progress",
43
+ isPlanning: false,
44
+ agentNames: ["mal", "kaylee", "inara"],
45
+ hasProject: false,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Reset
52
+ // ---------------------------------------------------------------------------
53
+
54
+ afterEach(() => {
55
+ vi.clearAllMocks();
56
+ runAgentMock.mockResolvedValue({
57
+ success: true,
58
+ output: '{"intent":"general","reasoning":"test"}',
59
+ });
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // classifyIntent — LLM path
64
+ // ---------------------------------------------------------------------------
65
+
66
+ describe("classifyIntent", () => {
67
+ it("parses valid intent from LLM response", async () => {
68
+ runAgentMock.mockResolvedValueOnce({
69
+ success: true,
70
+ output: '{"intent":"request_work","reasoning":"user wants something built"}',
71
+ });
72
+
73
+ const result = await classifyIntent(createApi(), createCtx({ commentBody: "fix the bug" }));
74
+
75
+ expect(result.intent).toBe("request_work");
76
+ expect(result.reasoning).toBe("user wants something built");
77
+ expect(result.fromFallback).toBe(false);
78
+ });
79
+
80
+ it("parses intent with extra text around JSON", async () => {
81
+ runAgentMock.mockResolvedValueOnce({
82
+ success: true,
83
+ output: 'Here is my analysis:\n{"intent":"question","reasoning":"user asking for help"}\nDone.',
84
+ });
85
+
86
+ const result = await classifyIntent(createApi(), createCtx());
87
+ expect(result.intent).toBe("question");
88
+ expect(result.fromFallback).toBe(false);
89
+ });
90
+
91
+ it("populates agentId for ask_agent with valid name", async () => {
92
+ runAgentMock.mockResolvedValueOnce({
93
+ success: true,
94
+ output: '{"intent":"ask_agent","agentId":"Kaylee","reasoning":"user addressing kaylee"}',
95
+ });
96
+
97
+ const result = await classifyIntent(createApi(), createCtx({
98
+ commentBody: "hey kaylee look at this",
99
+ }));
100
+
101
+ expect(result.intent).toBe("ask_agent");
102
+ expect(result.agentId).toBe("kaylee");
103
+ });
104
+
105
+ it("clears agentId for ask_agent with hallucinated name", async () => {
106
+ runAgentMock.mockResolvedValueOnce({
107
+ success: true,
108
+ output: '{"intent":"ask_agent","agentId":"wash","reasoning":"user wants wash"}',
109
+ });
110
+
111
+ const result = await classifyIntent(createApi(), createCtx());
112
+
113
+ expect(result.intent).toBe("ask_agent");
114
+ expect(result.agentId).toBeUndefined();
115
+ });
116
+
117
+ it("falls back to regex when LLM returns invalid JSON", async () => {
118
+ runAgentMock.mockResolvedValueOnce({
119
+ success: true,
120
+ output: "I cannot determine the intent",
121
+ });
122
+
123
+ const result = await classifyIntent(createApi(), createCtx());
124
+ expect(result.fromFallback).toBe(true);
125
+ });
126
+
127
+ it("falls back to regex when LLM returns invalid intent enum", async () => {
128
+ runAgentMock.mockResolvedValueOnce({
129
+ success: true,
130
+ output: '{"intent":"destroy_everything","reasoning":"chaos"}',
131
+ });
132
+
133
+ const result = await classifyIntent(createApi(), createCtx());
134
+ expect(result.fromFallback).toBe(true);
135
+ });
136
+
137
+ it("falls back to regex when LLM call fails", async () => {
138
+ runAgentMock.mockResolvedValueOnce({
139
+ success: false,
140
+ output: "Agent error",
141
+ });
142
+
143
+ const result = await classifyIntent(createApi(), createCtx());
144
+ expect(result.fromFallback).toBe(true);
145
+ });
146
+
147
+ it("falls back to regex when LLM call throws", async () => {
148
+ runAgentMock.mockRejectedValueOnce(new Error("timeout"));
149
+
150
+ const result = await classifyIntent(createApi(), createCtx());
151
+ expect(result.fromFallback).toBe(true);
152
+ });
153
+
154
+ it("uses 12s timeout for classification", async () => {
155
+ await classifyIntent(createApi(), createCtx());
156
+
157
+ expect(runAgentMock).toHaveBeenCalledWith(
158
+ expect.objectContaining({
159
+ timeoutMs: 12_000,
160
+ }),
161
+ );
162
+ });
163
+
164
+ it("includes context in the prompt", async () => {
165
+ await classifyIntent(createApi(), createCtx({
166
+ commentBody: "what can I do?",
167
+ issueTitle: "Auth Feature",
168
+ isPlanning: true,
169
+ }));
170
+
171
+ const call = runAgentMock.mock.calls[0][0];
172
+ expect(call.message).toContain("Auth Feature");
173
+ expect(call.message).toContain("Planning mode: true");
174
+ expect(call.message).toContain("what can I do?");
175
+ });
176
+
177
+ it("uses classifierAgentId from pluginConfig when configured", async () => {
178
+ await classifyIntent(
179
+ createApi({ classifierAgentId: "haiku-classifier" }),
180
+ createCtx(),
181
+ { classifierAgentId: "haiku-classifier" },
182
+ );
183
+
184
+ expect(runAgentMock).toHaveBeenCalledWith(
185
+ expect.objectContaining({
186
+ agentId: "haiku-classifier",
187
+ }),
188
+ );
189
+ });
190
+
191
+ it("truncates long comments to 500 chars", async () => {
192
+ const longComment = "x".repeat(1000);
193
+ await classifyIntent(createApi(), createCtx({ commentBody: longComment }));
194
+
195
+ const call = runAgentMock.mock.calls[0][0];
196
+ expect(call.message).not.toContain("x".repeat(501));
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // regexFallback
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("regexFallback", () => {
205
+ describe("planning mode active", () => {
206
+ it("detects finalize intent", () => {
207
+ const result = regexFallback(createCtx({
208
+ isPlanning: true,
209
+ commentBody: "finalize the plan",
210
+ }));
211
+ expect(result.intent).toBe("plan_finalize");
212
+ expect(result.fromFallback).toBe(true);
213
+ });
214
+
215
+ it("detects approve plan intent", () => {
216
+ const result = regexFallback(createCtx({
217
+ isPlanning: true,
218
+ commentBody: "approve plan",
219
+ }));
220
+ expect(result.intent).toBe("plan_finalize");
221
+ });
222
+
223
+ it("detects abandon intent", () => {
224
+ const result = regexFallback(createCtx({
225
+ isPlanning: true,
226
+ commentBody: "abandon planning",
227
+ }));
228
+ expect(result.intent).toBe("plan_abandon");
229
+ expect(result.fromFallback).toBe(true);
230
+ });
231
+
232
+ it("detects cancel planning", () => {
233
+ const result = regexFallback(createCtx({
234
+ isPlanning: true,
235
+ commentBody: "cancel planning",
236
+ }));
237
+ expect(result.intent).toBe("plan_abandon");
238
+ });
239
+
240
+ it("defaults to plan_continue for unmatched text", () => {
241
+ const result = regexFallback(createCtx({
242
+ isPlanning: true,
243
+ commentBody: "add a search feature please",
244
+ }));
245
+ expect(result.intent).toBe("plan_continue");
246
+ expect(result.fromFallback).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe("not planning", () => {
251
+ it("detects plan_start when issue has project", () => {
252
+ const result = regexFallback(createCtx({
253
+ hasProject: true,
254
+ commentBody: "plan this project",
255
+ }));
256
+ expect(result.intent).toBe("plan_start");
257
+ expect(result.fromFallback).toBe(true);
258
+ });
259
+
260
+ it("does NOT detect plan_start without project", () => {
261
+ const result = regexFallback(createCtx({
262
+ hasProject: false,
263
+ commentBody: "plan this project",
264
+ }));
265
+ expect(result.intent).toBe("general");
266
+ });
267
+
268
+ it("detects agent name in comment", () => {
269
+ const result = regexFallback(createCtx({
270
+ commentBody: "hey kaylee check this out",
271
+ }));
272
+ expect(result.intent).toBe("ask_agent");
273
+ expect(result.agentId).toBe("kaylee");
274
+ });
275
+
276
+ it("returns general for no pattern match", () => {
277
+ const result = regexFallback(createCtx({
278
+ commentBody: "thanks for the update",
279
+ agentNames: [],
280
+ }));
281
+ expect(result.intent).toBe("general");
282
+ expect(result.fromFallback).toBe(true);
283
+ });
284
+ });
285
+ });