@calltelemetry/openclaw-linear 0.8.0 → 0.8.2
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 +340 -376
- 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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intent-classify.ts — LLM-based intent classification for Linear comments.
|
|
3
|
+
*
|
|
4
|
+
* Replaces static regex pattern matching with a lightweight LLM classifier.
|
|
5
|
+
* Follows the tier-assess.ts pattern: runAgent() subprocess call, JSON parsing,
|
|
6
|
+
* regex fallback on any failure.
|
|
7
|
+
*
|
|
8
|
+
* Cost: one short agent turn (~300 tokens). Latency: ~2-5s.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export type Intent =
|
|
19
|
+
| "plan_start"
|
|
20
|
+
| "plan_finalize"
|
|
21
|
+
| "plan_abandon"
|
|
22
|
+
| "plan_continue"
|
|
23
|
+
| "ask_agent"
|
|
24
|
+
| "request_work"
|
|
25
|
+
| "question"
|
|
26
|
+
| "general";
|
|
27
|
+
|
|
28
|
+
export interface IntentResult {
|
|
29
|
+
intent: Intent;
|
|
30
|
+
agentId?: string;
|
|
31
|
+
reasoning: string;
|
|
32
|
+
fromFallback: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IntentContext {
|
|
36
|
+
commentBody: string;
|
|
37
|
+
issueTitle: string;
|
|
38
|
+
issueStatus?: string;
|
|
39
|
+
isPlanning: boolean;
|
|
40
|
+
/** Names of available agents (e.g. ["mal", "kaylee", "inara"]) */
|
|
41
|
+
agentNames: string[];
|
|
42
|
+
/** Whether the issue belongs to a project */
|
|
43
|
+
hasProject: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Valid intents (for validation)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const VALID_INTENTS: Set<string> = new Set([
|
|
51
|
+
"plan_start",
|
|
52
|
+
"plan_finalize",
|
|
53
|
+
"plan_abandon",
|
|
54
|
+
"plan_continue",
|
|
55
|
+
"ask_agent",
|
|
56
|
+
"request_work",
|
|
57
|
+
"question",
|
|
58
|
+
"general",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Classifier prompt
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const CLASSIFY_PROMPT = `You are an intent classifier for a developer tool. Respond ONLY with JSON.
|
|
66
|
+
|
|
67
|
+
Intents:
|
|
68
|
+
- plan_start: user wants to begin project planning
|
|
69
|
+
- plan_finalize: user wants to approve/finalize the plan (e.g. "looks good", "ship it", "approve plan")
|
|
70
|
+
- plan_abandon: user wants to cancel/stop planning (e.g. "nevermind", "cancel this", "stop planning")
|
|
71
|
+
- plan_continue: regular message during planning (default when planning is active)
|
|
72
|
+
- ask_agent: user is addressing a specific agent by name
|
|
73
|
+
- request_work: user wants something built, fixed, or implemented
|
|
74
|
+
- question: user asking for information or help
|
|
75
|
+
- general: none of the above, automated messages, or noise
|
|
76
|
+
|
|
77
|
+
Rules:
|
|
78
|
+
- plan_start ONLY if the issue belongs to a project (hasProject=true)
|
|
79
|
+
- If planning mode is active and no clear finalize/abandon intent, default to plan_continue
|
|
80
|
+
- For ask_agent, set agentId to the matching name from Available agents
|
|
81
|
+
- One sentence reasoning`;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Classify
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Classify a comment's intent using a lightweight model.
|
|
89
|
+
*
|
|
90
|
+
* Uses `classifierAgentId` from plugin config (should point to a small/fast
|
|
91
|
+
* model like Haiku for low latency and cost). Falls back to the default
|
|
92
|
+
* agent if not configured.
|
|
93
|
+
*
|
|
94
|
+
* Falls back to regex patterns if the LLM call fails or returns invalid JSON.
|
|
95
|
+
*/
|
|
96
|
+
export async function classifyIntent(
|
|
97
|
+
api: OpenClawPluginApi,
|
|
98
|
+
ctx: IntentContext,
|
|
99
|
+
pluginConfig?: Record<string, unknown>,
|
|
100
|
+
): Promise<IntentResult> {
|
|
101
|
+
const contextBlock = [
|
|
102
|
+
`Issue: "${ctx.issueTitle}" (status: ${ctx.issueStatus ?? "unknown"})`,
|
|
103
|
+
`Planning mode: ${ctx.isPlanning}`,
|
|
104
|
+
`Has project: ${ctx.hasProject}`,
|
|
105
|
+
`Available agents: ${ctx.agentNames.join(", ") || "none"}`,
|
|
106
|
+
`Comment: "${ctx.commentBody.slice(0, 500)}"`,
|
|
107
|
+
].join("\n");
|
|
108
|
+
|
|
109
|
+
const message = `${CLASSIFY_PROMPT}\n\nContext:\n${contextBlock}\n\nRespond ONLY with: {"intent":"<intent>","agentId":"<if ask_agent>","reasoning":"<one sentence>"}`;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { runAgent } = await import("../agent/agent.js");
|
|
113
|
+
const classifierAgent = resolveClassifierAgent(api, pluginConfig);
|
|
114
|
+
const result = await runAgent({
|
|
115
|
+
api,
|
|
116
|
+
agentId: classifierAgent,
|
|
117
|
+
sessionId: `intent-classify-${Date.now()}`,
|
|
118
|
+
message,
|
|
119
|
+
timeoutMs: 12_000, // 12s — fast classification
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result.output) {
|
|
123
|
+
const parsed = parseIntentResponse(result.output, ctx);
|
|
124
|
+
if (parsed) {
|
|
125
|
+
api.logger.info(`Intent classified: ${parsed.intent}${parsed.agentId ? ` (agent: ${parsed.agentId})` : ""} — ${parsed.reasoning}`);
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
api.logger.warn(`Intent classifier agent failed: ${result.output.slice(0, 200)}`);
|
|
132
|
+
} else {
|
|
133
|
+
api.logger.warn(`Intent classifier: could not parse response: ${result.output.slice(0, 200)}`);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
api.logger.warn(`Intent classifier error: ${err}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fallback to regex
|
|
140
|
+
const fallback = regexFallback(ctx);
|
|
141
|
+
api.logger.info(`Intent classifier fallback: ${fallback.intent} — ${fallback.reasoning}`);
|
|
142
|
+
return fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Response parsing
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
function parseIntentResponse(raw: string, ctx: IntentContext): IntentResult | null {
|
|
150
|
+
// Extract JSON using indexOf/lastIndexOf (more robust than regex for nested JSON)
|
|
151
|
+
const start = raw.indexOf("{");
|
|
152
|
+
const end = raw.lastIndexOf("}");
|
|
153
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(raw.slice(start, end + 1));
|
|
157
|
+
const intent = parsed.intent as string;
|
|
158
|
+
|
|
159
|
+
if (!VALID_INTENTS.has(intent)) return null;
|
|
160
|
+
|
|
161
|
+
// Validate agentId for ask_agent
|
|
162
|
+
let agentId: string | undefined;
|
|
163
|
+
if (intent === "ask_agent" && parsed.agentId) {
|
|
164
|
+
const normalized = String(parsed.agentId).toLowerCase();
|
|
165
|
+
// Only accept agent names that actually exist
|
|
166
|
+
if (ctx.agentNames.some((n) => n.toLowerCase() === normalized)) {
|
|
167
|
+
agentId = normalized;
|
|
168
|
+
}
|
|
169
|
+
// If hallucinated name, clear agentId but keep the intent
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
intent: intent as Intent,
|
|
174
|
+
agentId,
|
|
175
|
+
reasoning: parsed.reasoning ?? "no reasoning provided",
|
|
176
|
+
fromFallback: false,
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Regex fallback (moved from planner.ts + webhook.ts)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
// Planning intent patterns
|
|
188
|
+
const PLAN_START_PATTERN = /\b(plan|planning)\s+(this\s+)(project|out)\b|\bplan\s+this\s+out\b/i;
|
|
189
|
+
const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
|
|
190
|
+
const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
|
|
191
|
+
|
|
192
|
+
export function regexFallback(ctx: IntentContext): IntentResult {
|
|
193
|
+
const text = ctx.commentBody;
|
|
194
|
+
|
|
195
|
+
// Planning-specific patterns (only when planning is active or issue has project)
|
|
196
|
+
if (ctx.isPlanning) {
|
|
197
|
+
if (FINALIZE_PATTERN.test(text)) {
|
|
198
|
+
return { intent: "plan_finalize", reasoning: "regex: finalize pattern matched", fromFallback: true };
|
|
199
|
+
}
|
|
200
|
+
if (ABANDON_PATTERN.test(text)) {
|
|
201
|
+
return { intent: "plan_abandon", reasoning: "regex: abandon pattern matched", fromFallback: true };
|
|
202
|
+
}
|
|
203
|
+
// Default to plan_continue during planning
|
|
204
|
+
return { intent: "plan_continue", reasoning: "regex: planning mode active, default continue", fromFallback: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Plan start (only if issue has a project)
|
|
208
|
+
if (ctx.hasProject && PLAN_START_PATTERN.test(text)) {
|
|
209
|
+
return { intent: "plan_start", reasoning: "regex: plan start pattern matched", fromFallback: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Agent name detection
|
|
213
|
+
if (ctx.agentNames.length > 0) {
|
|
214
|
+
const lower = text.toLowerCase();
|
|
215
|
+
for (const name of ctx.agentNames) {
|
|
216
|
+
if (lower.includes(name.toLowerCase())) {
|
|
217
|
+
return { intent: "ask_agent", agentId: name.toLowerCase(), reasoning: `regex: agent name "${name}" found in comment`, fromFallback: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Default: general (no match)
|
|
223
|
+
return { intent: "general", reasoning: "regex: no pattern matched", fromFallback: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Helpers
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the agent to use for intent classification.
|
|
232
|
+
*
|
|
233
|
+
* Priority: pluginConfig.classifierAgentId → defaultAgentId → profile default.
|
|
234
|
+
* Configure classifierAgentId to point to a small/fast model (e.g. Haiku)
|
|
235
|
+
* for low-latency, low-cost classification.
|
|
236
|
+
*/
|
|
237
|
+
function resolveClassifierAgent(api: OpenClawPluginApi, pluginConfig?: Record<string, unknown>): string {
|
|
238
|
+
// 1. Explicit classifier agent
|
|
239
|
+
const classifierAgent = pluginConfig?.classifierAgentId ?? (api as any).pluginConfig?.classifierAgentId;
|
|
240
|
+
if (typeof classifierAgent === "string" && classifierAgent) return classifierAgent;
|
|
241
|
+
|
|
242
|
+
// 2. Fall back to default agent
|
|
243
|
+
return resolveDefaultAgent(api);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveDefaultAgent(api: OpenClawPluginApi): string {
|
|
247
|
+
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
248
|
+
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
252
|
+
const raw = readFileSync(profilesPath, "utf8");
|
|
253
|
+
const profiles = JSON.parse(raw).agents ?? {};
|
|
254
|
+
const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
|
|
255
|
+
if (defaultAgent) return defaultAgent[0];
|
|
256
|
+
} catch { /* fall through */ }
|
|
257
|
+
|
|
258
|
+
return "default";
|
|
259
|
+
}
|
|
@@ -16,6 +16,17 @@ vi.mock("../api/linear-api.js", () => ({}));
|
|
|
16
16
|
|
|
17
17
|
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
18
18
|
|
|
19
|
+
// Mock CLI tool runners for cross-model review
|
|
20
|
+
vi.mock("../tools/claude-tool.js", () => ({
|
|
21
|
+
runClaude: vi.fn().mockResolvedValue({ success: true, output: "Claude review feedback" }),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../tools/codex-tool.js", () => ({
|
|
24
|
+
runCodex: vi.fn().mockResolvedValue({ success: true, output: "Codex review feedback" }),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("../tools/gemini-tool.js", () => ({
|
|
27
|
+
runGemini: vi.fn().mockResolvedValue({ success: true, output: "Gemini review feedback" }),
|
|
28
|
+
}));
|
|
29
|
+
|
|
19
30
|
const mockLinearApi = {
|
|
20
31
|
getProject: vi.fn().mockResolvedValue({
|
|
21
32
|
id: "proj-1",
|
|
@@ -61,7 +72,13 @@ vi.mock("../tools/planner-tools.js", () => ({
|
|
|
61
72
|
// Imports (AFTER mocks)
|
|
62
73
|
// ---------------------------------------------------------------------------
|
|
63
74
|
|
|
64
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
initiatePlanningSession,
|
|
77
|
+
handlePlannerTurn,
|
|
78
|
+
runPlanAudit,
|
|
79
|
+
runCrossModelReview,
|
|
80
|
+
resolveReviewModel,
|
|
81
|
+
} from "./planner.js";
|
|
65
82
|
import {
|
|
66
83
|
registerPlanningSession,
|
|
67
84
|
updatePlanningSession,
|
|
@@ -73,6 +90,9 @@ import {
|
|
|
73
90
|
clearActivePlannerContext,
|
|
74
91
|
auditPlan,
|
|
75
92
|
} from "../tools/planner-tools.js";
|
|
93
|
+
import { runClaude } from "../tools/claude-tool.js";
|
|
94
|
+
import { runCodex } from "../tools/codex-tool.js";
|
|
95
|
+
import { runGemini } from "../tools/gemini-tool.js";
|
|
76
96
|
|
|
77
97
|
// ---------------------------------------------------------------------------
|
|
78
98
|
// Helpers
|
|
@@ -230,47 +250,31 @@ describe("handlePlannerTurn", () => {
|
|
|
230
250
|
);
|
|
231
251
|
});
|
|
232
252
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
await handlePlannerTurn(ctx, session, {
|
|
238
|
-
issueId: "issue-1",
|
|
239
|
-
commentBody: "finalize plan",
|
|
240
|
-
commentorName: "Tester",
|
|
241
|
-
});
|
|
253
|
+
// Note: finalize/abandon intent detection has moved to webhook.ts via
|
|
254
|
+
// intent-classify.ts. handlePlannerTurn is now a pure "continue planning"
|
|
255
|
+
// function that always runs the agent.
|
|
256
|
+
});
|
|
242
257
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
});
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// runPlanAudit
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
247
261
|
|
|
248
|
-
|
|
262
|
+
describe("runPlanAudit", () => {
|
|
263
|
+
it("transitions to plan_review on passing audit", async () => {
|
|
264
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
249
265
|
const ctx = createCtx();
|
|
250
266
|
const session = createSession();
|
|
251
267
|
|
|
252
|
-
await
|
|
253
|
-
issueId: "issue-1",
|
|
254
|
-
commentBody: "abandon planning",
|
|
255
|
-
commentorName: "Tester",
|
|
256
|
-
});
|
|
268
|
+
await runPlanAudit(ctx, session);
|
|
257
269
|
|
|
258
|
-
expect(
|
|
270
|
+
expect(updatePlanningSession).toHaveBeenCalledWith(
|
|
259
271
|
"proj-1",
|
|
260
|
-
"
|
|
272
|
+
{ status: "plan_review" },
|
|
261
273
|
undefined,
|
|
262
274
|
);
|
|
263
|
-
// Should NOT run the agent
|
|
264
|
-
expect(runAgentMock).not.toHaveBeenCalled();
|
|
265
275
|
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
// runPlanAudit
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
276
|
|
|
272
|
-
|
|
273
|
-
it("posts success comment on passing audit", async () => {
|
|
277
|
+
it("posts 'Passed Checks' comment on passing audit", async () => {
|
|
274
278
|
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
275
279
|
const ctx = createCtx();
|
|
276
280
|
const session = createSession();
|
|
@@ -279,24 +283,46 @@ describe("runPlanAudit", () => {
|
|
|
279
283
|
|
|
280
284
|
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
281
285
|
"issue-1",
|
|
282
|
-
expect.stringContaining("
|
|
286
|
+
expect.stringContaining("Plan Passed Checks"),
|
|
283
287
|
);
|
|
284
288
|
});
|
|
285
289
|
|
|
286
|
-
it("
|
|
290
|
+
it("runs cross-model review automatically on passing audit", async () => {
|
|
287
291
|
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
288
292
|
const ctx = createCtx();
|
|
289
293
|
const session = createSession();
|
|
290
294
|
|
|
291
295
|
await runPlanAudit(ctx, session);
|
|
292
296
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
// Default review model is "gemini" (since no primary model configured)
|
|
298
|
+
expect(runGemini).toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("runs planner agent with review prompt including cross-model feedback", async () => {
|
|
302
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
303
|
+
const ctx = createCtx();
|
|
304
|
+
const session = createSession();
|
|
305
|
+
|
|
306
|
+
await runPlanAudit(ctx, session);
|
|
307
|
+
|
|
308
|
+
// Agent should run with a review prompt
|
|
309
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
message: expect.stringContaining("Plan Review"),
|
|
312
|
+
}),
|
|
297
313
|
);
|
|
298
314
|
});
|
|
299
315
|
|
|
316
|
+
it("does NOT end session as approved on passing audit (waits for user approval)", async () => {
|
|
317
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
318
|
+
const ctx = createCtx();
|
|
319
|
+
const session = createSession();
|
|
320
|
+
|
|
321
|
+
await runPlanAudit(ctx, session);
|
|
322
|
+
|
|
323
|
+
expect(endPlanningSession).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
300
326
|
it("posts problems on failing audit", async () => {
|
|
301
327
|
vi.mocked(auditPlan).mockReturnValue({
|
|
302
328
|
pass: false,
|
|
@@ -314,7 +340,7 @@ describe("runPlanAudit", () => {
|
|
|
314
340
|
);
|
|
315
341
|
});
|
|
316
342
|
|
|
317
|
-
it("does NOT
|
|
343
|
+
it("does NOT transition to plan_review on failing audit", async () => {
|
|
318
344
|
vi.mocked(auditPlan).mockReturnValue({
|
|
319
345
|
pass: false,
|
|
320
346
|
problems: ["No estimates"],
|
|
@@ -325,10 +351,103 @@ describe("runPlanAudit", () => {
|
|
|
325
351
|
|
|
326
352
|
await runPlanAudit(ctx, session);
|
|
327
353
|
|
|
328
|
-
expect(
|
|
354
|
+
expect(updatePlanningSession).not.toHaveBeenCalledWith(
|
|
329
355
|
"proj-1",
|
|
330
|
-
"
|
|
356
|
+
{ status: "plan_review" },
|
|
331
357
|
expect.anything(),
|
|
332
358
|
);
|
|
333
359
|
});
|
|
360
|
+
|
|
361
|
+
it("includes warnings in success comment when present", async () => {
|
|
362
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
363
|
+
pass: true,
|
|
364
|
+
problems: [],
|
|
365
|
+
warnings: ["PROJ-3 has no acceptance criteria"],
|
|
366
|
+
});
|
|
367
|
+
const ctx = createCtx();
|
|
368
|
+
const session = createSession();
|
|
369
|
+
|
|
370
|
+
await runPlanAudit(ctx, session);
|
|
371
|
+
|
|
372
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
373
|
+
"issue-1",
|
|
374
|
+
expect.stringContaining("PROJ-3 has no acceptance criteria"),
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// resolveReviewModel
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
describe("resolveReviewModel", () => {
|
|
384
|
+
it("returns 'codex' when primary model is claude-based", () => {
|
|
385
|
+
expect(resolveReviewModel({
|
|
386
|
+
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4" } } },
|
|
387
|
+
} as any)).toBe("codex");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("returns 'gemini' when primary model is codex-based", () => {
|
|
391
|
+
expect(resolveReviewModel({
|
|
392
|
+
agents: { defaults: { model: { primary: "openai/codex-3" } } },
|
|
393
|
+
} as any)).toBe("gemini");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("returns 'codex' when primary model is gemini-based", () => {
|
|
397
|
+
expect(resolveReviewModel({
|
|
398
|
+
agents: { defaults: { model: { primary: "google/gemini-2" } } },
|
|
399
|
+
} as any)).toBe("codex");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("returns 'gemini' when no primary model configured", () => {
|
|
403
|
+
expect(resolveReviewModel({})).toBe("gemini");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("respects explicit plannerReviewModel config override", () => {
|
|
407
|
+
expect(resolveReviewModel({
|
|
408
|
+
plannerReviewModel: "gemini",
|
|
409
|
+
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4" } } },
|
|
410
|
+
} as any)).toBe("gemini");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// runCrossModelReview
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
describe("runCrossModelReview", () => {
|
|
419
|
+
it("calls the correct CLI runner for the specified model", async () => {
|
|
420
|
+
const api = createApi();
|
|
421
|
+
|
|
422
|
+
await runCrossModelReview(api, "claude", "test snapshot");
|
|
423
|
+
expect(runClaude).toHaveBeenCalled();
|
|
424
|
+
|
|
425
|
+
vi.clearAllMocks();
|
|
426
|
+
await runCrossModelReview(api, "codex", "test snapshot");
|
|
427
|
+
expect(runCodex).toHaveBeenCalled();
|
|
428
|
+
|
|
429
|
+
vi.clearAllMocks();
|
|
430
|
+
await runCrossModelReview(api, "gemini", "test snapshot");
|
|
431
|
+
expect(runGemini).toHaveBeenCalled();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("returns review output on success", async () => {
|
|
435
|
+
const api = createApi();
|
|
436
|
+
const result = await runCrossModelReview(api, "claude", "test snapshot");
|
|
437
|
+
expect(result).toBe("Claude review feedback");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("returns graceful fallback on failure", async () => {
|
|
441
|
+
vi.mocked(runClaude).mockResolvedValueOnce({ success: false, error: "timeout" } as any);
|
|
442
|
+
const api = createApi();
|
|
443
|
+
const result = await runCrossModelReview(api, "claude", "test snapshot");
|
|
444
|
+
expect(result).toContain("review failed");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("returns graceful fallback on exception", async () => {
|
|
448
|
+
vi.mocked(runClaude).mockRejectedValueOnce(new Error("network error"));
|
|
449
|
+
const api = createApi();
|
|
450
|
+
const result = await runCrossModelReview(api, "claude", "test snapshot");
|
|
451
|
+
expect(result).toContain("review unavailable");
|
|
452
|
+
});
|
|
334
453
|
});
|