@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.
- package/README.md +152 -34
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +27 -1
- package/src/agent/agent.test.ts +49 -0
- package/src/agent/agent.ts +26 -1
- package/src/infra/doctor.ts +2 -2
- package/src/pipeline/e2e-planning.test.ts +77 -54
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +98 -32
- package/src/pipeline/webhook.ts +322 -226
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/code-tool.test.ts +3 -3
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* E2E planning pipeline tests.
|
|
3
3
|
*
|
|
4
4
|
* Exercises the real planning lifecycle: initiatePlanningSession → handlePlannerTurn
|
|
5
|
-
* → runPlanAudit →
|
|
5
|
+
* → runPlanAudit → plan_review → (webhook handles approval) → DAG dispatch cascade.
|
|
6
6
|
*
|
|
7
|
-
* Mocked: runAgent, LinearAgentApi. Real: planning-state.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: "
|
|
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: "
|
|
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: "
|
|
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 →
|
|
125
|
+
// Test 1: Full lifecycle — initiate → interview → audit → plan_review
|
|
115
126
|
// =========================================================================
|
|
116
|
-
it("full lifecycle: initiate → interview turns →
|
|
127
|
+
it("full lifecycle: initiate → interview turns → audit → plan_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:
|
|
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
|
-
|
|
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"
|
|
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
|
|
215
|
+
expect.stringContaining("Plan Passed Checks"),
|
|
208
216
|
);
|
|
209
217
|
|
|
210
|
-
//
|
|
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("
|
|
220
|
+
expect(state.sessions["proj-1"].status).toBe("plan_review");
|
|
213
221
|
|
|
214
|
-
//
|
|
215
|
-
expect(
|
|
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-
|
|
227
|
+
// Test 2: Audit fail → fix issues → re-audit → plan_review
|
|
220
228
|
// =========================================================================
|
|
221
|
-
it("audit fail → fix issues → re-
|
|
229
|
+
it("audit fail → fix issues → re-audit → plan_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
|
|
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
|
|
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
|
|
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
|
-
|
|
261
|
-
await runPlanAudit(ctx, session, { onApproved });
|
|
269
|
+
await runPlanAudit(ctx, session);
|
|
262
270
|
|
|
263
|
-
// Now should be
|
|
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
|
|
274
|
+
expect.stringContaining("Plan Passed Checks"),
|
|
267
275
|
);
|
|
268
276
|
|
|
269
277
|
state = await readPlanningState(configPath);
|
|
270
|
-
expect(state.sessions["proj-1"].status).toBe("
|
|
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:
|
|
282
|
+
// Test 3: handlePlannerTurn is pure continue — no intent detection
|
|
276
283
|
// =========================================================================
|
|
277
|
-
it("
|
|
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: "
|
|
302
|
+
commentBody: "finalize plan",
|
|
288
303
|
commentorName: "User",
|
|
289
304
|
});
|
|
290
305
|
|
|
291
|
-
|
|
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:
|
|
311
|
+
// Test 4: Audit with warnings still passes
|
|
304
312
|
// =========================================================================
|
|
305
|
-
it("
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
await runPlanAudit(ctx, session, { onApproved });
|
|
331
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Review with warnings." });
|
|
317
332
|
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
});
|