@calltelemetry/openclaw-linear 0.6.1 → 0.7.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/LICENSE +21 -0
- package/README.md +115 -17
- package/index.ts +57 -22
- package/openclaw.plugin.json +37 -4
- package/package.json +2 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +193 -19
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +284 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +4 -4
- package/src/infra/doctor.ts +7 -29
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +222 -43
- 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.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 +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +287 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +178 -0
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +90 -17
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
getActiveDispatch,
|
|
35
35
|
} from "./dispatch-state.js";
|
|
36
36
|
import { type NotifyFn } from "../infra/notify.js";
|
|
37
|
+
import { onProjectIssueCompleted, onProjectIssueStuck } from "./dag-dispatch.js";
|
|
37
38
|
import {
|
|
38
39
|
saveWorkerOutput,
|
|
39
40
|
saveAuditVerdict,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
resolveOrchestratorWorkspace,
|
|
46
47
|
} from "./artifacts.js";
|
|
47
48
|
import { resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
49
|
+
import { emitDiagnostic } from "../infra/observability.js";
|
|
48
50
|
|
|
49
51
|
// ---------------------------------------------------------------------------
|
|
50
52
|
// Prompt loading
|
|
@@ -70,13 +72,29 @@ const DEFAULT_PROMPTS: PromptTemplates = {
|
|
|
70
72
|
},
|
|
71
73
|
};
|
|
72
74
|
|
|
73
|
-
let
|
|
75
|
+
let _cachedGlobalPrompts: PromptTemplates | null = null;
|
|
76
|
+
const _projectPromptCache = new Map<string, PromptTemplates>();
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Merge two prompt layers. Overlay replaces individual fields per section
|
|
80
|
+
* (shallow section-level merge, not deep).
|
|
81
|
+
*/
|
|
82
|
+
function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplates>): PromptTemplates {
|
|
83
|
+
return {
|
|
84
|
+
worker: { ...base.worker, ...overlay.worker },
|
|
85
|
+
audit: { ...base.audit, ...overlay.audit },
|
|
86
|
+
rework: { ...base.rework, ...overlay.rework },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
|
|
92
|
+
* Cached after first load.
|
|
93
|
+
*/
|
|
94
|
+
function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
|
|
95
|
+
if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
|
|
77
96
|
|
|
78
97
|
try {
|
|
79
|
-
// Try custom path first
|
|
80
98
|
const customPath = pluginConfig?.promptsPath as string | undefined;
|
|
81
99
|
let raw: string;
|
|
82
100
|
|
|
@@ -86,27 +104,52 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
|
|
|
86
104
|
: customPath;
|
|
87
105
|
raw = readFileSync(resolved, "utf-8");
|
|
88
106
|
} else {
|
|
89
|
-
// Load from plugin directory (sidecar file)
|
|
90
107
|
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
91
108
|
raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
95
|
-
|
|
96
|
-
worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
|
|
97
|
-
audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
|
|
98
|
-
rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
|
|
99
|
-
};
|
|
112
|
+
_cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed);
|
|
100
113
|
} catch {
|
|
101
|
-
|
|
114
|
+
_cachedGlobalPrompts = DEFAULT_PROMPTS;
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
return
|
|
117
|
+
return _cachedGlobalPrompts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load prompts with three-layer merge:
|
|
122
|
+
* 1. Built-in defaults (hardcoded DEFAULT_PROMPTS)
|
|
123
|
+
* 2. Global override (promptsPath or sidecar prompts.yaml)
|
|
124
|
+
* 3. Per-project override ({worktreePath}/.claw/prompts.yaml) — optional
|
|
125
|
+
*/
|
|
126
|
+
export function loadPrompts(pluginConfig?: Record<string, unknown>, worktreePath?: string): PromptTemplates {
|
|
127
|
+
const global = loadGlobalPrompts(pluginConfig);
|
|
128
|
+
|
|
129
|
+
if (!worktreePath) return global;
|
|
130
|
+
|
|
131
|
+
// Check per-project cache
|
|
132
|
+
const cached = _projectPromptCache.get(worktreePath);
|
|
133
|
+
if (cached) return cached;
|
|
134
|
+
|
|
135
|
+
// Try loading per-project prompts
|
|
136
|
+
try {
|
|
137
|
+
const projectPromptsPath = join(worktreePath, ".claw", "prompts.yaml");
|
|
138
|
+
const raw = readFileSync(projectPromptsPath, "utf-8");
|
|
139
|
+
const parsed = parseYaml(raw) as Partial<PromptTemplates>;
|
|
140
|
+
const merged = mergePromptLayers(global, parsed);
|
|
141
|
+
_projectPromptCache.set(worktreePath, merged);
|
|
142
|
+
return merged;
|
|
143
|
+
} catch {
|
|
144
|
+
// No per-project override — use global
|
|
145
|
+
return global;
|
|
146
|
+
}
|
|
105
147
|
}
|
|
106
148
|
|
|
107
149
|
/** Clear prompt cache (for testing or after config change) */
|
|
108
150
|
export function clearPromptCache(): void {
|
|
109
|
-
|
|
151
|
+
_cachedGlobalPrompts = null;
|
|
152
|
+
_projectPromptCache.clear();
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
@@ -137,7 +180,7 @@ export function buildWorkerTask(
|
|
|
137
180
|
worktreePath: string,
|
|
138
181
|
opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
|
|
139
182
|
): { system: string; task: string } {
|
|
140
|
-
const prompts = loadPrompts(opts?.pluginConfig);
|
|
183
|
+
const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
|
|
141
184
|
const vars: Record<string, string> = {
|
|
142
185
|
identifier: issue.identifier,
|
|
143
186
|
title: issue.title,
|
|
@@ -167,7 +210,7 @@ export function buildAuditTask(
|
|
|
167
210
|
worktreePath: string,
|
|
168
211
|
pluginConfig?: Record<string, unknown>,
|
|
169
212
|
): { system: string; task: string } {
|
|
170
|
-
const prompts = loadPrompts(pluginConfig);
|
|
213
|
+
const prompts = loadPrompts(pluginConfig, worktreePath);
|
|
171
214
|
const vars: Record<string, string> = {
|
|
172
215
|
identifier: issue.identifier,
|
|
173
216
|
title: issue.title,
|
|
@@ -274,6 +317,7 @@ export async function triggerAudit(
|
|
|
274
317
|
}
|
|
275
318
|
|
|
276
319
|
api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
|
|
320
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "working", to: "auditing", attempt: dispatch.attempt });
|
|
277
321
|
|
|
278
322
|
// Update .claw/ manifest
|
|
279
323
|
try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
|
|
@@ -464,7 +508,14 @@ async function handleAuditPass(
|
|
|
464
508
|
if (summary) {
|
|
465
509
|
writeSummary(dispatch.worktreePath, summary);
|
|
466
510
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
467
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
511
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
512
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
513
|
+
tier: dispatch.tier,
|
|
514
|
+
status: "done",
|
|
515
|
+
project: dispatch.project,
|
|
516
|
+
attempts: dispatch.attempt + 1,
|
|
517
|
+
model: dispatch.model,
|
|
518
|
+
});
|
|
468
519
|
api.logger.info(`${TAG} .claw/ summary and memory written`);
|
|
469
520
|
}
|
|
470
521
|
} catch (err) {
|
|
@@ -480,6 +531,7 @@ async function handleAuditPass(
|
|
|
480
531
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
481
532
|
|
|
482
533
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
534
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "done", attempt: dispatch.attempt });
|
|
483
535
|
|
|
484
536
|
await notify("audit_pass", {
|
|
485
537
|
identifier: dispatch.issueIdentifier,
|
|
@@ -489,6 +541,12 @@ async function handleAuditPass(
|
|
|
489
541
|
verdict: { pass: true, gaps: [] },
|
|
490
542
|
});
|
|
491
543
|
|
|
544
|
+
// DAG cascade: if this issue belongs to a project dispatch, check for newly unblocked issues
|
|
545
|
+
if (dispatch.project) {
|
|
546
|
+
void onProjectIssueCompleted(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
547
|
+
.catch((err) => api.logger.error(`${TAG} DAG cascade error: ${err}`));
|
|
548
|
+
}
|
|
549
|
+
|
|
492
550
|
clearActiveSession(dispatch.issueId);
|
|
493
551
|
}
|
|
494
552
|
|
|
@@ -531,7 +589,14 @@ async function handleAuditFail(
|
|
|
531
589
|
if (summary) {
|
|
532
590
|
writeSummary(dispatch.worktreePath, summary);
|
|
533
591
|
const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
|
|
534
|
-
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir
|
|
592
|
+
writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
|
|
593
|
+
title: dispatch.issueTitle ?? dispatch.issueIdentifier,
|
|
594
|
+
tier: dispatch.tier,
|
|
595
|
+
status: "stuck",
|
|
596
|
+
project: dispatch.project,
|
|
597
|
+
attempts: nextAttempt,
|
|
598
|
+
model: dispatch.model,
|
|
599
|
+
});
|
|
535
600
|
}
|
|
536
601
|
} catch {}
|
|
537
602
|
|
|
@@ -542,6 +607,7 @@ async function handleAuditFail(
|
|
|
542
607
|
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
543
608
|
|
|
544
609
|
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
610
|
+
emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "stuck", attempt: nextAttempt });
|
|
545
611
|
|
|
546
612
|
await notify("escalation", {
|
|
547
613
|
identifier: dispatch.issueIdentifier,
|
|
@@ -552,6 +618,12 @@ async function handleAuditFail(
|
|
|
552
618
|
verdict: { pass: false, gaps: verdict.gaps },
|
|
553
619
|
});
|
|
554
620
|
|
|
621
|
+
// DAG cascade: mark this issue as stuck in the project dispatch
|
|
622
|
+
if (dispatch.project) {
|
|
623
|
+
void onProjectIssueStuck(hookCtx, dispatch.project, dispatch.issueIdentifier)
|
|
624
|
+
.catch((err) => api.logger.error(`${TAG} DAG stuck cascade error: ${err}`));
|
|
625
|
+
}
|
|
626
|
+
|
|
555
627
|
return;
|
|
556
628
|
}
|
|
557
629
|
|
|
@@ -579,6 +651,7 @@ async function handleAuditFail(
|
|
|
579
651
|
).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
|
|
580
652
|
|
|
581
653
|
api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
|
|
654
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "auditing", to: "working", attempt: nextAttempt });
|
|
582
655
|
|
|
583
656
|
await notify("audit_fail", {
|
|
584
657
|
identifier: dispatch.issueIdentifier,
|
|
@@ -698,6 +771,7 @@ export async function spawnWorker(
|
|
|
698
771
|
const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
|
|
699
772
|
|
|
700
773
|
api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
|
|
774
|
+
emitDiagnostic(api, { event: "watchdog_kill", identifier: dispatch.issueIdentifier, attempt: dispatch.attempt });
|
|
701
775
|
|
|
702
776
|
try {
|
|
703
777
|
appendLog(dispatch.worktreePath, {
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks (vi.hoisted + vi.mock)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const { runAgentMock } = vi.hoisted(() => ({
|
|
8
|
+
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../agent/agent.js", () => ({
|
|
12
|
+
runAgent: runAgentMock,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../api/linear-api.js", () => ({}));
|
|
16
|
+
|
|
17
|
+
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
18
|
+
|
|
19
|
+
const mockLinearApi = {
|
|
20
|
+
getProject: vi.fn().mockResolvedValue({
|
|
21
|
+
id: "proj-1",
|
|
22
|
+
name: "Test Project",
|
|
23
|
+
teams: { nodes: [{ id: "team-1", name: "Team" }] },
|
|
24
|
+
}),
|
|
25
|
+
getProjectIssues: vi.fn().mockResolvedValue([]),
|
|
26
|
+
getTeamStates: vi.fn().mockResolvedValue([
|
|
27
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
28
|
+
]),
|
|
29
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
30
|
+
createComment: vi.fn().mockResolvedValue("comment-id"),
|
|
31
|
+
getIssueDetails: vi.fn().mockResolvedValue({
|
|
32
|
+
id: "issue-1",
|
|
33
|
+
identifier: "PROJ-1",
|
|
34
|
+
title: "Root",
|
|
35
|
+
comments: { nodes: [] },
|
|
36
|
+
project: { id: "proj-1" },
|
|
37
|
+
team: { id: "team-1" },
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
vi.mock("./planning-state.js", () => ({
|
|
42
|
+
registerPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
updatePlanningSession: vi.fn().mockResolvedValue({
|
|
44
|
+
turnCount: 1,
|
|
45
|
+
projectId: "proj-1",
|
|
46
|
+
status: "interviewing",
|
|
47
|
+
}),
|
|
48
|
+
endPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
setPlanningCache: vi.fn(),
|
|
50
|
+
clearPlanningCache: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock("../tools/planner-tools.js", () => ({
|
|
54
|
+
setActivePlannerContext: vi.fn(),
|
|
55
|
+
clearActivePlannerContext: vi.fn(),
|
|
56
|
+
buildPlanSnapshot: vi.fn().mockReturnValue("_No issues created yet._"),
|
|
57
|
+
auditPlan: vi.fn().mockReturnValue({ pass: true, problems: [], warnings: [] }),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Imports (AFTER mocks)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
65
|
+
import {
|
|
66
|
+
registerPlanningSession,
|
|
67
|
+
updatePlanningSession,
|
|
68
|
+
endPlanningSession,
|
|
69
|
+
setPlanningCache,
|
|
70
|
+
} from "./planning-state.js";
|
|
71
|
+
import {
|
|
72
|
+
setActivePlannerContext,
|
|
73
|
+
clearActivePlannerContext,
|
|
74
|
+
auditPlan,
|
|
75
|
+
} from "../tools/planner-tools.js";
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function createApi() {
|
|
82
|
+
return {
|
|
83
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
84
|
+
pluginConfig: {},
|
|
85
|
+
} as any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createCtx(overrides?: Partial<{ api: any; linearApi: any; pluginConfig: any }>) {
|
|
89
|
+
return {
|
|
90
|
+
api: createApi(),
|
|
91
|
+
linearApi: mockLinearApi,
|
|
92
|
+
pluginConfig: {},
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createSession(overrides?: Record<string, unknown>) {
|
|
98
|
+
return {
|
|
99
|
+
projectId: "proj-1",
|
|
100
|
+
projectName: "Test Project",
|
|
101
|
+
rootIssueId: "issue-1",
|
|
102
|
+
rootIdentifier: "PROJ-1",
|
|
103
|
+
teamId: "team-1",
|
|
104
|
+
status: "interviewing" as const,
|
|
105
|
+
startedAt: new Date().toISOString(),
|
|
106
|
+
turnCount: 0,
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Reset
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.clearAllMocks();
|
|
117
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
|
|
118
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// initiatePlanningSession
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("initiatePlanningSession", () => {
|
|
126
|
+
const rootIssue = {
|
|
127
|
+
id: "issue-1",
|
|
128
|
+
identifier: "PROJ-1",
|
|
129
|
+
title: "Root",
|
|
130
|
+
team: { id: "team-1" },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
it("registers session in state with projectId and status interviewing", async () => {
|
|
134
|
+
const ctx = createCtx();
|
|
135
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
136
|
+
|
|
137
|
+
expect(registerPlanningSession).toHaveBeenCalledWith(
|
|
138
|
+
"proj-1",
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
projectId: "proj-1",
|
|
141
|
+
status: "interviewing",
|
|
142
|
+
}),
|
|
143
|
+
undefined,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("sets planning cache with the session", async () => {
|
|
148
|
+
const ctx = createCtx();
|
|
149
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
150
|
+
|
|
151
|
+
expect(setPlanningCache).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
projectId: "proj-1",
|
|
154
|
+
projectName: "Test Project",
|
|
155
|
+
status: "interviewing",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("posts welcome comment containing the project name", async () => {
|
|
161
|
+
const ctx = createCtx();
|
|
162
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
163
|
+
|
|
164
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
165
|
+
"issue-1",
|
|
166
|
+
expect.stringContaining("Test Project"),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("fetches project metadata and team states", async () => {
|
|
171
|
+
const ctx = createCtx();
|
|
172
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
173
|
+
|
|
174
|
+
expect(mockLinearApi.getProject).toHaveBeenCalledWith("proj-1");
|
|
175
|
+
expect(mockLinearApi.getTeamStates).toHaveBeenCalledWith("team-1");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// handlePlannerTurn
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("handlePlannerTurn", () => {
|
|
184
|
+
const input = {
|
|
185
|
+
issueId: "issue-1",
|
|
186
|
+
commentBody: "Let's add a search feature",
|
|
187
|
+
commentorName: "Tester",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
it("increments turn count via updatePlanningSession", async () => {
|
|
191
|
+
const ctx = createCtx();
|
|
192
|
+
const session = createSession();
|
|
193
|
+
await handlePlannerTurn(ctx, session, input);
|
|
194
|
+
|
|
195
|
+
expect(updatePlanningSession).toHaveBeenCalledWith(
|
|
196
|
+
"proj-1",
|
|
197
|
+
{ turnCount: 1 },
|
|
198
|
+
undefined,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("builds plan snapshot from project issues", async () => {
|
|
203
|
+
const ctx = createCtx();
|
|
204
|
+
const session = createSession();
|
|
205
|
+
await handlePlannerTurn(ctx, session, input);
|
|
206
|
+
|
|
207
|
+
expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith("proj-1");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("calls runAgent with system prompt", async () => {
|
|
211
|
+
const ctx = createCtx();
|
|
212
|
+
const session = createSession();
|
|
213
|
+
await handlePlannerTurn(ctx, session, input);
|
|
214
|
+
|
|
215
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
message: expect.stringContaining("planning"),
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("posts agent response as comment", async () => {
|
|
223
|
+
const ctx = createCtx();
|
|
224
|
+
const session = createSession();
|
|
225
|
+
await handlePlannerTurn(ctx, session, input);
|
|
226
|
+
|
|
227
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
228
|
+
"issue-1",
|
|
229
|
+
"Mock planner response",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("detects finalize plan intent and triggers audit instead of regular turn", async () => {
|
|
234
|
+
const ctx = createCtx();
|
|
235
|
+
const session = createSession();
|
|
236
|
+
|
|
237
|
+
await handlePlannerTurn(ctx, session, {
|
|
238
|
+
issueId: "issue-1",
|
|
239
|
+
commentBody: "finalize plan",
|
|
240
|
+
commentorName: "Tester",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Audit path: auditPlan is called, runAgent is NOT called
|
|
244
|
+
expect(auditPlan).toHaveBeenCalled();
|
|
245
|
+
expect(runAgentMock).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("detects abandon intent and ends session as abandoned", async () => {
|
|
249
|
+
const ctx = createCtx();
|
|
250
|
+
const session = createSession();
|
|
251
|
+
|
|
252
|
+
await handlePlannerTurn(ctx, session, {
|
|
253
|
+
issueId: "issue-1",
|
|
254
|
+
commentBody: "abandon",
|
|
255
|
+
commentorName: "Tester",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(endPlanningSession).toHaveBeenCalledWith(
|
|
259
|
+
"proj-1",
|
|
260
|
+
"abandoned",
|
|
261
|
+
undefined,
|
|
262
|
+
);
|
|
263
|
+
// Should NOT run the agent
|
|
264
|
+
expect(runAgentMock).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// runPlanAudit
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
describe("runPlanAudit", () => {
|
|
273
|
+
it("posts success comment on passing audit", async () => {
|
|
274
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
275
|
+
const ctx = createCtx();
|
|
276
|
+
const session = createSession();
|
|
277
|
+
|
|
278
|
+
await runPlanAudit(ctx, session);
|
|
279
|
+
|
|
280
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
281
|
+
"issue-1",
|
|
282
|
+
expect.stringContaining("Approved"),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("ends session as approved on pass", async () => {
|
|
287
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
288
|
+
const ctx = createCtx();
|
|
289
|
+
const session = createSession();
|
|
290
|
+
|
|
291
|
+
await runPlanAudit(ctx, session);
|
|
292
|
+
|
|
293
|
+
expect(endPlanningSession).toHaveBeenCalledWith(
|
|
294
|
+
"proj-1",
|
|
295
|
+
"approved",
|
|
296
|
+
undefined,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("posts problems on failing audit", async () => {
|
|
301
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
302
|
+
pass: false,
|
|
303
|
+
problems: ["Missing description on PROJ-2"],
|
|
304
|
+
warnings: [],
|
|
305
|
+
});
|
|
306
|
+
const ctx = createCtx();
|
|
307
|
+
const session = createSession();
|
|
308
|
+
|
|
309
|
+
await runPlanAudit(ctx, session);
|
|
310
|
+
|
|
311
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
312
|
+
"issue-1",
|
|
313
|
+
expect.stringContaining("Missing description on PROJ-2"),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("does NOT end session as approved on fail", async () => {
|
|
318
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
319
|
+
pass: false,
|
|
320
|
+
problems: ["No estimates"],
|
|
321
|
+
warnings: [],
|
|
322
|
+
});
|
|
323
|
+
const ctx = createCtx();
|
|
324
|
+
const session = createSession();
|
|
325
|
+
|
|
326
|
+
await runPlanAudit(ctx, session);
|
|
327
|
+
|
|
328
|
+
expect(endPlanningSession).not.toHaveBeenCalledWith(
|
|
329
|
+
"proj-1",
|
|
330
|
+
"approved",
|
|
331
|
+
expect.anything(),
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|