@ilya-lesikov/pi-pi 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
- package/extensions/orchestrator/agents/constraints.test.ts +39 -1
- package/extensions/orchestrator/agents/constraints.ts +63 -2
- package/extensions/orchestrator/agents/tool-routing.ts +30 -14
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +43 -27
- package/extensions/orchestrator/config.test.ts +40 -1
- package/extensions/orchestrator/config.ts +13 -0
- package/extensions/orchestrator/context.ts +16 -0
- package/extensions/orchestrator/custom-footer.test.ts +91 -0
- package/extensions/orchestrator/custom-footer.ts +20 -20
- package/extensions/orchestrator/event-handlers.test.ts +315 -1
- package/extensions/orchestrator/event-handlers.ts +397 -201
- package/extensions/orchestrator/integration.test.ts +305 -9
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.ts +46 -58
- package/extensions/orchestrator/phases/brainstorm.ts +11 -7
- package/extensions/orchestrator/phases/machine.test.ts +36 -0
- package/extensions/orchestrator/phases/machine.ts +11 -1
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +44 -16
- package/extensions/orchestrator/phases/review.test.ts +26 -0
- package/extensions/orchestrator/phases/review.ts +40 -3
- package/extensions/orchestrator/pp-menu.test.ts +207 -1
- package/extensions/orchestrator/pp-menu.ts +376 -378
- package/extensions/orchestrator/state.test.ts +9 -0
- package/extensions/orchestrator/state.ts +15 -0
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/package.json +1 -1
- package/AGENTS.md +0 -28
|
@@ -43,6 +43,7 @@ export interface AfterImplementCommandConfig {
|
|
|
43
43
|
export interface PiPiConfig {
|
|
44
44
|
general: {
|
|
45
45
|
autoCommit: boolean;
|
|
46
|
+
injectAgentsMd: boolean;
|
|
46
47
|
loadExtraRepoConfigs: boolean;
|
|
47
48
|
logLevel: LogLevel;
|
|
48
49
|
tracing: boolean;
|
|
@@ -65,6 +66,7 @@ export interface PiPiConfig {
|
|
|
65
66
|
};
|
|
66
67
|
internals: {
|
|
67
68
|
subagentStale: DurationValue;
|
|
69
|
+
mainTurnStale: DurationValue;
|
|
68
70
|
taskLockStale: DurationValue;
|
|
69
71
|
taskLockRefresh: DurationValue;
|
|
70
72
|
};
|
|
@@ -79,6 +81,7 @@ export interface NormalizedPiPiConfig extends PiPiConfig {
|
|
|
79
81
|
};
|
|
80
82
|
internals: {
|
|
81
83
|
subagentStale: number;
|
|
84
|
+
mainTurnStale: number;
|
|
82
85
|
taskLockStale: number;
|
|
83
86
|
taskLockRefresh: number;
|
|
84
87
|
};
|
|
@@ -97,6 +100,7 @@ const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "ta
|
|
|
97
100
|
const DEFAULT_CONFIG: PiPiConfig = {
|
|
98
101
|
general: {
|
|
99
102
|
autoCommit: true,
|
|
103
|
+
injectAgentsMd: true,
|
|
100
104
|
loadExtraRepoConfigs: true,
|
|
101
105
|
logLevel: "info",
|
|
102
106
|
tracing: false,
|
|
@@ -203,6 +207,7 @@ const DEFAULT_CONFIG: PiPiConfig = {
|
|
|
203
207
|
},
|
|
204
208
|
internals: {
|
|
205
209
|
subagentStale: "5m",
|
|
210
|
+
mainTurnStale: "10m",
|
|
206
211
|
taskLockStale: "1m",
|
|
207
212
|
taskLockRefresh: "30s",
|
|
208
213
|
},
|
|
@@ -347,6 +352,7 @@ export function validateConfig(config: Record<string, any>): void {
|
|
|
347
352
|
if (config.general !== undefined) {
|
|
348
353
|
const general = requireObject(config.general, "config.general");
|
|
349
354
|
ensureBool(general.autoCommit, "config.general.autoCommit");
|
|
355
|
+
ensureBool(general.injectAgentsMd, "config.general.injectAgentsMd");
|
|
350
356
|
ensureBool(general.loadExtraRepoConfigs, "config.general.loadExtraRepoConfigs");
|
|
351
357
|
ensureBool(general.tracing, "config.general.tracing");
|
|
352
358
|
if (general.logLevel !== undefined && !isValidLogLevel(general.logLevel)) {
|
|
@@ -417,6 +423,7 @@ export function validateConfig(config: Record<string, any>): void {
|
|
|
417
423
|
if (performance.internals !== undefined) {
|
|
418
424
|
const internals = requireObject(performance.internals, "config.performance.internals");
|
|
419
425
|
ensureDuration(internals.subagentStale, "config.performance.internals.subagentStale");
|
|
426
|
+
ensureDuration(internals.mainTurnStale, "config.performance.internals.mainTurnStale");
|
|
420
427
|
ensureDuration(internals.taskLockStale, "config.performance.internals.taskLockStale");
|
|
421
428
|
ensureDuration(internals.taskLockRefresh, "config.performance.internals.taskLockRefresh");
|
|
422
429
|
}
|
|
@@ -526,6 +533,9 @@ export function validateMergedConfig(config: Record<string, any>): void {
|
|
|
526
533
|
if (parseDuration(typed.performance.internals.subagentStale) === null) {
|
|
527
534
|
throw new Error("config.performance.internals.subagentStale must be a valid duration");
|
|
528
535
|
}
|
|
536
|
+
if (parseDuration(typed.performance.internals.mainTurnStale) === null) {
|
|
537
|
+
throw new Error("config.performance.internals.mainTurnStale must be a valid duration");
|
|
538
|
+
}
|
|
529
539
|
if (parseDuration(typed.performance.internals.taskLockStale) === null) {
|
|
530
540
|
throw new Error("config.performance.internals.taskLockStale must be a valid duration");
|
|
531
541
|
}
|
|
@@ -540,6 +550,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
540
550
|
const afterEdit = parseDuration(next.performance.commands.afterEdit);
|
|
541
551
|
const afterImplement = parseDuration(next.performance.commands.afterImplement);
|
|
542
552
|
const subagentStale = parseDuration(next.performance.internals.subagentStale);
|
|
553
|
+
const mainTurnStale = parseDuration(next.performance.internals.mainTurnStale);
|
|
543
554
|
const taskLockStale = parseDuration(next.performance.internals.taskLockStale);
|
|
544
555
|
const taskLockRefresh = parseDuration(next.performance.internals.taskLockRefresh);
|
|
545
556
|
|
|
@@ -547,6 +558,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
547
558
|
afterEdit === null ||
|
|
548
559
|
afterImplement === null ||
|
|
549
560
|
subagentStale === null ||
|
|
561
|
+
mainTurnStale === null ||
|
|
550
562
|
taskLockStale === null ||
|
|
551
563
|
taskLockRefresh === null
|
|
552
564
|
) {
|
|
@@ -556,6 +568,7 @@ export function normalizeConfigDurations(config: PiPiConfig): NormalizedPiPiConf
|
|
|
556
568
|
next.performance.commands.afterEdit = afterEdit;
|
|
557
569
|
next.performance.commands.afterImplement = afterImplement;
|
|
558
570
|
next.performance.internals.subagentStale = subagentStale;
|
|
571
|
+
next.performance.internals.mainTurnStale = mainTurnStale;
|
|
559
572
|
next.performance.internals.taskLockStale = taskLockStale;
|
|
560
573
|
next.performance.internals.taskLockRefresh = taskLockRefresh;
|
|
561
574
|
return next;
|
|
@@ -279,6 +279,22 @@ export function getLatestSynthesizedPlan(taskDir: string): string | null {
|
|
|
279
279
|
return readFileSync(join(plansDir, synthFiles[synthFiles.length - 1]), "utf-8");
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// True when the newest `code-reviews/*_final_pass-*.md` exists and carries an
|
|
283
|
+
// `ANCHORS:` block. Shared by publishGuard (Publish menu) and the review-phase
|
|
284
|
+
// completion gates so a review cannot finish without the deliverable Publish
|
|
285
|
+
// consumes. A zero-findings `ANCHORS: (none)` still satisfies this (the line exists).
|
|
286
|
+
export function hasFinalPassAnchors(taskDir: string): boolean {
|
|
287
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
288
|
+
if (!existsSync(reviewsDir)) return false;
|
|
289
|
+
const finalPassFiles = readdirSync(reviewsDir)
|
|
290
|
+
.filter((f) => f.endsWith(".md") && f.includes("_final_pass-"))
|
|
291
|
+
.sort();
|
|
292
|
+
if (finalPassFiles.length === 0) return false;
|
|
293
|
+
const latest = finalPassFiles[finalPassFiles.length - 1];
|
|
294
|
+
const content = readFileSync(join(reviewsDir, latest), "utf8");
|
|
295
|
+
return /^ANCHORS:/m.test(content);
|
|
296
|
+
}
|
|
297
|
+
|
|
282
298
|
function getLatestSynthesizedPlanPath(taskDir: string): string | null {
|
|
283
299
|
const plansDir = join(taskDir, "plans");
|
|
284
300
|
if (!existsSync(plansDir)) return null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
|
|
3
|
+
|
|
4
|
+
const theme = { fg: (_color: string, text: string) => text } as any;
|
|
5
|
+
const footerData = { getGitBranch: () => "main" } as any;
|
|
6
|
+
|
|
7
|
+
function render(width = 200): string[] {
|
|
8
|
+
const footer = createCustomFooter({} as any, theme, footerData);
|
|
9
|
+
return footer.render(width);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeCtx(usage?: { tokens: number | null; contextWindow: number; percent: number | null }): any {
|
|
13
|
+
return {
|
|
14
|
+
cwd: "/tmp/project",
|
|
15
|
+
model: { id: "test-model", provider: "test" },
|
|
16
|
+
sessionManager: { getSessionName: () => undefined, getEntries: () => [] },
|
|
17
|
+
getContextUsage: () => usage,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("createCustomFooter", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
setFooterTracker(undefined as any);
|
|
24
|
+
setFooterOrchestrator(undefined as any);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders exactly two lines (no status/LSP line)", () => {
|
|
28
|
+
setFooterContext(makeCtx());
|
|
29
|
+
const lines = render();
|
|
30
|
+
expect(lines).toHaveLength(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("line 1 shows task/phase/mode when a task is active", () => {
|
|
34
|
+
setFooterContext(makeCtx());
|
|
35
|
+
setFooterOrchestrator({
|
|
36
|
+
active: { type: "implement", state: { phase: "plan", mode: "autonomous" } },
|
|
37
|
+
} as any);
|
|
38
|
+
const [line1] = render();
|
|
39
|
+
expect(line1).toContain("task: implement");
|
|
40
|
+
expect(line1).toContain("phase: plan");
|
|
41
|
+
expect(line1).toContain("mode: autonomous");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("line 1 shows autonomous mode in a read-only phase for an autonomous task", () => {
|
|
45
|
+
setFooterContext(makeCtx());
|
|
46
|
+
setFooterOrchestrator({
|
|
47
|
+
active: { type: "implement", state: { phase: "brainstorm", mode: "autonomous" } },
|
|
48
|
+
} as any);
|
|
49
|
+
const [line1] = render();
|
|
50
|
+
expect(line1).toContain("phase: brainstorm");
|
|
51
|
+
expect(line1).toContain("mode: autonomous");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("line 1 shows guided mode for a guided task", () => {
|
|
55
|
+
setFooterContext(makeCtx());
|
|
56
|
+
setFooterOrchestrator({
|
|
57
|
+
active: { type: "implement", state: { phase: "implement", mode: "guided" } },
|
|
58
|
+
} as any);
|
|
59
|
+
const [line1] = render();
|
|
60
|
+
expect(line1).toContain("mode: guided");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("line 1 omits the mode segment for a quick task", () => {
|
|
64
|
+
setFooterContext(makeCtx());
|
|
65
|
+
setFooterOrchestrator({
|
|
66
|
+
active: { type: "quick", state: { phase: "quick", mode: "autonomous" } },
|
|
67
|
+
} as any);
|
|
68
|
+
const [line1] = render();
|
|
69
|
+
expect(line1).toContain("task: quick");
|
|
70
|
+
expect(line1).not.toContain("mode:");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("line 1 omits task metadata when no task is active", () => {
|
|
74
|
+
setFooterContext(makeCtx());
|
|
75
|
+
const [line1] = render();
|
|
76
|
+
expect(line1).not.toContain("task:");
|
|
77
|
+
expect(line1).toContain("main");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("context indicator shows percent/used/max", () => {
|
|
81
|
+
setFooterContext(makeCtx({ tokens: 38000, contextWindow: 1000000, percent: 3.8 }));
|
|
82
|
+
const [, line2] = render();
|
|
83
|
+
expect(line2).toContain("3.8%/38k/1.0M (auto)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("context indicator degrades gracefully when tokens are null", () => {
|
|
87
|
+
setFooterContext(makeCtx({ tokens: null, contextWindow: 1000000, percent: null }));
|
|
88
|
+
const [, line2] = render();
|
|
89
|
+
expect(line2).toContain("?%/?/1.0M (auto)");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -2,9 +2,12 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { UsageTracker } from "./usage-tracker.js";
|
|
5
|
+
import type { Orchestrator } from "./orchestrator.js";
|
|
6
|
+
import { formatModeIndicator } from "./state.js";
|
|
5
7
|
|
|
6
8
|
let footerCtx: ExtensionContext | undefined;
|
|
7
9
|
let footerTracker: UsageTracker | undefined;
|
|
10
|
+
let footerOrchestrator: Orchestrator | undefined;
|
|
8
11
|
|
|
9
12
|
export function setFooterContext(ctx: ExtensionContext): void {
|
|
10
13
|
footerCtx = ctx;
|
|
@@ -14,6 +17,10 @@ export function setFooterTracker(tracker: UsageTracker): void {
|
|
|
14
17
|
footerTracker = tracker;
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
export function setFooterOrchestrator(orchestrator: Orchestrator): void {
|
|
21
|
+
footerOrchestrator = orchestrator;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
function formatTokens(count: number): string {
|
|
18
25
|
if (count < 1000) return count.toString();
|
|
19
26
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
@@ -22,10 +29,6 @@ function formatTokens(count: number): string {
|
|
|
22
29
|
return `${Math.round(count / 1000000)}M`;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
function sanitizeStatusText(text: string): string {
|
|
26
|
-
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
32
|
function formatPath(cwd: string): string {
|
|
30
33
|
const home = homedir();
|
|
31
34
|
return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
@@ -47,8 +50,10 @@ function toContextUsagePart(ctx: ExtensionContext | undefined, theme: Theme): st
|
|
|
47
50
|
const usage = ctx?.getContextUsage();
|
|
48
51
|
const contextWindow = usage?.contextWindow ?? 0;
|
|
49
52
|
const percentValue = usage?.percent ?? null;
|
|
53
|
+
const tokensValue = usage?.tokens ?? null;
|
|
50
54
|
const percentText = percentValue === null ? "?" : percentValue.toFixed(1);
|
|
51
|
-
const
|
|
55
|
+
const tokensText = tokensValue === null ? "?" : formatTokens(tokensValue);
|
|
56
|
+
const display = `${percentText}%/${tokensText}/${formatTokens(contextWindow)} (auto)`;
|
|
52
57
|
if (percentValue !== null && percentValue > 90) return theme.fg("error", display);
|
|
53
58
|
if (percentValue !== null && percentValue > 70) return theme.fg("warning", display);
|
|
54
59
|
return display;
|
|
@@ -115,23 +120,19 @@ function renderPathLine(width: number, theme: Theme, footerData: ReadonlyFooterD
|
|
|
115
120
|
const ctx = footerCtx;
|
|
116
121
|
const path = formatPath(ctx?.cwd ?? process.cwd());
|
|
117
122
|
const branch = footerData.getGitBranch();
|
|
118
|
-
const sessionName = ctx?.sessionManager.getSessionName();
|
|
119
123
|
|
|
120
124
|
let line = path;
|
|
121
125
|
if (branch) line += ` (${branch})`;
|
|
122
|
-
if (sessionName) line += ` • ${sessionName}`;
|
|
123
|
-
|
|
124
|
-
return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function renderStatusLine(width: number, theme: Theme, footerData: ReadonlyFooterDataProvider): string | undefined {
|
|
128
|
-
const statuses = footerData.getExtensionStatuses();
|
|
129
|
-
if (statuses.size === 0) return undefined;
|
|
130
126
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
.
|
|
134
|
-
.
|
|
127
|
+
const task = footerOrchestrator?.active;
|
|
128
|
+
if (task && task.state.phase !== "done") {
|
|
129
|
+
line += ` • task: ${task.type} • phase: ${task.state.phase}`;
|
|
130
|
+
const mode = formatModeIndicator(task.state, task.type);
|
|
131
|
+
if (mode) line += ` • mode: ${mode}`;
|
|
132
|
+
} else {
|
|
133
|
+
const sessionName = ctx?.sessionManager.getSessionName();
|
|
134
|
+
if (sessionName) line += ` • ${sessionName}`;
|
|
135
|
+
}
|
|
135
136
|
|
|
136
137
|
return truncateToWidth(theme.fg("dim", line), width, theme.fg("dim", "..."));
|
|
137
138
|
}
|
|
@@ -141,8 +142,7 @@ export function createCustomFooter(_tui: TUI, theme: Theme, footerData: Readonly
|
|
|
141
142
|
render(width: number): string[] {
|
|
142
143
|
const line1 = renderPathLine(width, theme, footerData);
|
|
143
144
|
const line2 = renderStatsLine(width, theme);
|
|
144
|
-
|
|
145
|
-
return [line1, line2, line3];
|
|
145
|
+
return [line1, line2];
|
|
146
146
|
},
|
|
147
147
|
invalidate(): void {},
|
|
148
148
|
dispose(): void {},
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
-
import { registerEventHandlers } from "./event-handlers.js";
|
|
5
|
+
import { registerEventHandlers, checkoutPrHead, isReviewCycleLive, finalizeReviewCycle } from "./event-handlers.js";
|
|
6
6
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
7
7
|
import { getDefaultConfig } from "./config.js";
|
|
8
8
|
|
|
@@ -22,6 +22,7 @@ function makePi() {
|
|
|
22
22
|
emit: vi.fn(),
|
|
23
23
|
},
|
|
24
24
|
getAllTools: vi.fn().mockReturnValue([{ name: "lsp" }]),
|
|
25
|
+
registerTool: vi.fn(),
|
|
25
26
|
sendMessage: vi.fn(),
|
|
26
27
|
sendUserMessage: vi.fn(),
|
|
27
28
|
setModel: vi.fn(),
|
|
@@ -144,6 +145,55 @@ describe("tool_call write protection", () => {
|
|
|
144
145
|
});
|
|
145
146
|
});
|
|
146
147
|
|
|
148
|
+
describe("review-phase AI_COMMENT write guard", () => {
|
|
149
|
+
function reviewTask(): ActiveTask {
|
|
150
|
+
const task = makeActiveTask();
|
|
151
|
+
task.state.phase = "review";
|
|
152
|
+
return task;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it("allows an edit that only inserts an AI_COMMENT marker", async () => {
|
|
156
|
+
orchestrator.active = reviewTask();
|
|
157
|
+
const handler = getHandler("tool_call");
|
|
158
|
+
const result = await handler(
|
|
159
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 1;\n// AI_COMMENT: check" }] } },
|
|
160
|
+
{},
|
|
161
|
+
);
|
|
162
|
+
expect(result).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("blocks an edit that changes real code during review", async () => {
|
|
166
|
+
orchestrator.active = reviewTask();
|
|
167
|
+
const handler = getHandler("tool_call");
|
|
168
|
+
const result = await handler(
|
|
169
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
|
|
170
|
+
{},
|
|
171
|
+
);
|
|
172
|
+
expect(result?.block).toBe(true);
|
|
173
|
+
expect(result?.reason).toContain("AI_COMMENT");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("blocks a brand-new source file write during review", async () => {
|
|
177
|
+
orchestrator.active = reviewTask();
|
|
178
|
+
const handler = getHandler("tool_call");
|
|
179
|
+
const result = await handler(
|
|
180
|
+
{ toolName: "write", input: { path: "src/new.ts", content: "// AI_COMMENT: note\n" } },
|
|
181
|
+
{},
|
|
182
|
+
);
|
|
183
|
+
expect(result?.block).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not restrict source edits outside the review phase", async () => {
|
|
187
|
+
orchestrator.active = makeActiveTask(); // implement phase
|
|
188
|
+
const handler = getHandler("tool_call");
|
|
189
|
+
const result = await handler(
|
|
190
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
|
|
191
|
+
{},
|
|
192
|
+
);
|
|
193
|
+
expect(result).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
147
197
|
describe("tool_result implementation tracking", () => {
|
|
148
198
|
it("adds file to modifiedFiles when input uses path field", async () => {
|
|
149
199
|
orchestrator.active = makeActiveTask();
|
|
@@ -347,3 +397,267 @@ describe("tool_call Agent routing and spawn-time context injection", () => {
|
|
|
347
397
|
expect(input.prompt).toBe("find X");
|
|
348
398
|
});
|
|
349
399
|
});
|
|
400
|
+
|
|
401
|
+
describe("main-turn stall watchdog (BUG-2)", () => {
|
|
402
|
+
beforeEach(() => {
|
|
403
|
+
vi.useFakeTimers();
|
|
404
|
+
});
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
vi.useRealTimers();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("recovers a wedged main turn via the idle-gated single-send path", async () => {
|
|
410
|
+
orchestrator.active = makeActiveTask();
|
|
411
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
412
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
413
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
414
|
+
|
|
415
|
+
// A turn starts and then never terminates.
|
|
416
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
417
|
+
expect(orchestrator.mainTurnInFlight).toBe(true);
|
|
418
|
+
|
|
419
|
+
// Before the threshold: no recovery.
|
|
420
|
+
vi.advanceTimersByTime(30000);
|
|
421
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
422
|
+
|
|
423
|
+
// Past the threshold with no activity: watchdog fires once.
|
|
424
|
+
vi.advanceTimersByTime(61000);
|
|
425
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
426
|
+
expect(sendSpy.mock.calls[0][0]).toContain("stalled without completing");
|
|
427
|
+
expect(orchestrator.mainTurnRecovering).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("does not fire while the turn keeps making activity", async () => {
|
|
431
|
+
orchestrator.active = makeActiveTask();
|
|
432
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
433
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
434
|
+
|
|
435
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
436
|
+
for (let i = 0; i < 5; i++) {
|
|
437
|
+
vi.advanceTimersByTime(30000);
|
|
438
|
+
await getHandler("tool_call")({ toolName: "read", input: { path: "a.ts" } }, {});
|
|
439
|
+
}
|
|
440
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("does not fire after the turn terminates", async () => {
|
|
444
|
+
orchestrator.active = makeActiveTask();
|
|
445
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
446
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
447
|
+
|
|
448
|
+
const ctx = { ui: { setStatus: vi.fn(), setWorkingMessage: vi.fn(), notify: vi.fn() }, isIdle: () => true } as any;
|
|
449
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, ctx);
|
|
450
|
+
await getHandler("turn_end")({ type: "turn_end", turnIndex: 0, message: {}, toolResults: [] }, ctx);
|
|
451
|
+
expect(orchestrator.mainTurnInFlight).toBe(false);
|
|
452
|
+
vi.advanceTimersByTime(120000);
|
|
453
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("does not fire while an interactive dialogue is open (#11)", async () => {
|
|
457
|
+
orchestrator.active = makeActiveTask();
|
|
458
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
459
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
460
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
461
|
+
|
|
462
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
463
|
+
orchestrator.interactivePromptOpen = true;
|
|
464
|
+
// Well past the stale threshold: the user is parked on a prompt, not wedged.
|
|
465
|
+
vi.advanceTimersByTime(600000);
|
|
466
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
467
|
+
|
|
468
|
+
// Once the dialogue closes, a genuinely stalled turn recovers again.
|
|
469
|
+
orchestrator.interactivePromptOpen = false;
|
|
470
|
+
vi.advanceTimersByTime(61000);
|
|
471
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("does not fire while the subscription fallback dialogue is open (#11)", async () => {
|
|
475
|
+
orchestrator.active = makeActiveTask();
|
|
476
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
477
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
478
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
479
|
+
|
|
480
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
481
|
+
orchestrator.subFallbackDialogPending = true;
|
|
482
|
+
vi.advanceTimersByTime(600000);
|
|
483
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
484
|
+
orchestrator.subFallbackDialogPending = false;
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("checkoutPrHead", () => {
|
|
489
|
+
type GitResult = { code: number; stdout?: string; stderr?: string };
|
|
490
|
+
// A key maps to a single result, or an array consumed in call order (last entry repeats).
|
|
491
|
+
function makeGitOrchestrator(script: Record<string, GitResult | GitResult[]>) {
|
|
492
|
+
const calls: string[][] = [];
|
|
493
|
+
const seen: Record<string, number> = {};
|
|
494
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
495
|
+
calls.push(args);
|
|
496
|
+
const key = args.join(" ");
|
|
497
|
+
const matched = Object.keys(script).find((k) => key.startsWith(k));
|
|
498
|
+
const entry = matched ? script[matched] : { code: 0, stdout: "", stderr: "" };
|
|
499
|
+
let res: GitResult;
|
|
500
|
+
if (Array.isArray(entry)) {
|
|
501
|
+
const i = Math.min(seen[matched!] ?? 0, entry.length - 1);
|
|
502
|
+
seen[matched!] = (seen[matched!] ?? 0) + 1;
|
|
503
|
+
res = entry[i];
|
|
504
|
+
} else {
|
|
505
|
+
res = entry;
|
|
506
|
+
}
|
|
507
|
+
return { code: res.code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
508
|
+
});
|
|
509
|
+
return { orchestrator: { pi: { exec } } as any, calls };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
it("is a no-op when no PR head is provided (non-PR review scope)", async () => {
|
|
513
|
+
const { orchestrator, calls } = makeGitOrchestrator({});
|
|
514
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "", "");
|
|
515
|
+
expect(result.ok).toBe(true);
|
|
516
|
+
expect(result.message).toContain("non-PR review scope");
|
|
517
|
+
expect(calls).toHaveLength(0);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("HALTs on a dirty working tree without checking out", async () => {
|
|
521
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
522
|
+
"status --porcelain": { code: 0, stdout: " M src/a.ts\n" },
|
|
523
|
+
});
|
|
524
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
525
|
+
expect(result.ok).toBe(false);
|
|
526
|
+
expect(result.message).toContain("HALT");
|
|
527
|
+
expect(result.message).toContain("uncommitted changes");
|
|
528
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("HALTs when on a different branch, and never runs git checkout", async () => {
|
|
532
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
533
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
534
|
+
"rev-parse HEAD": { code: 0, stdout: "othersha\n" },
|
|
535
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
|
|
536
|
+
});
|
|
537
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
538
|
+
expect(result.ok).toBe(false);
|
|
539
|
+
expect(result.message).toContain("HALT");
|
|
540
|
+
expect(result.message).toContain("feature");
|
|
541
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
542
|
+
expect(calls.some((c) => c[0] === "merge")).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("fast-forwards a clean branch that is behind the PR head, without checkout or force", async () => {
|
|
546
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
547
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
548
|
+
"rev-parse HEAD": [{ code: 0, stdout: "oldsha\n" }, { code: 0, stdout: "abc123\n" }],
|
|
549
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
550
|
+
"merge --ff-only abc123": { code: 0, stdout: "Updating oldsha..abc123\n" },
|
|
551
|
+
});
|
|
552
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
553
|
+
expect(result.ok).toBe(true);
|
|
554
|
+
expect(result.message).toContain("fast-forwarded");
|
|
555
|
+
expect(calls.some((c) => c[0] === "merge" && c[1] === "--ff-only" && c[2] === "abc123")).toBe(true);
|
|
556
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("HALTs when the branch is AHEAD of the PR head (ff-only is a no-op that leaves HEAD ahead)", async () => {
|
|
560
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
561
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
562
|
+
// ff-only to an ancestor returns exit 0 ("Already up to date") without moving HEAD.
|
|
563
|
+
"rev-parse HEAD": { code: 0, stdout: "aheadsha\n" },
|
|
564
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
565
|
+
"merge --ff-only abc123": { code: 0, stdout: "Already up to date.\n" },
|
|
566
|
+
});
|
|
567
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
568
|
+
expect(result.ok).toBe(false);
|
|
569
|
+
expect(result.message).toContain("HALT");
|
|
570
|
+
expect(result.message).toContain("ahead of the PR head");
|
|
571
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("HALTs when the branch has diverged and cannot fast-forward", async () => {
|
|
575
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
576
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
577
|
+
"rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
|
|
578
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
579
|
+
"merge --ff-only abc123": { code: 1, stderr: "fatal: Not possible to fast-forward, aborting." },
|
|
580
|
+
});
|
|
581
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
582
|
+
expect(result.ok).toBe(false);
|
|
583
|
+
expect(result.message).toContain("HALT");
|
|
584
|
+
expect(result.message).toContain("diverged");
|
|
585
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("succeeds without any checkout or merge when already on the PR head commit", async () => {
|
|
589
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
590
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
591
|
+
"rev-parse HEAD": { code: 0, stdout: "abc123\n" },
|
|
592
|
+
});
|
|
593
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
594
|
+
expect(result.ok).toBe(true);
|
|
595
|
+
expect(result.message).toContain("on PR head");
|
|
596
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("succeeds on a detached HEAD already at the PR head (re-entrant)", async () => {
|
|
600
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
601
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
602
|
+
"rev-parse HEAD": { code: 0, stdout: "abc123\n" },
|
|
603
|
+
});
|
|
604
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
605
|
+
expect(result.ok).toBe(true);
|
|
606
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("HALTs on a detached HEAD that is not at the PR head", async () => {
|
|
610
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
611
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
612
|
+
"rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
|
|
613
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "HEAD\n" },
|
|
614
|
+
});
|
|
615
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
616
|
+
expect(result.ok).toBe(false);
|
|
617
|
+
expect(result.message).toContain("detached HEAD");
|
|
618
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe("pp_checkout_pr_head tool registration", () => {
|
|
623
|
+
it("registers the checkout tool during orchestrator tool setup", async () => {
|
|
624
|
+
const { registerOrchestratorToolsForTest } = await import("./event-handlers.js");
|
|
625
|
+
orchestrator.active = makeActiveTask();
|
|
626
|
+
registerOrchestratorToolsForTest(orchestrator);
|
|
627
|
+
const names = (pi.registerTool as any).mock.calls.map((c: any[]) => c[0].name);
|
|
628
|
+
expect(names).toContain("pp_checkout_pr_head");
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe("isReviewCycleLive (#3b re-entrancy guard)", () => {
|
|
633
|
+
it("is true only for a cycle awaiting reviewers", () => {
|
|
634
|
+
const task = makeActiveTask();
|
|
635
|
+
task.state.reviewCycle = null;
|
|
636
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
637
|
+
task.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass: 1 };
|
|
638
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
639
|
+
task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
|
|
640
|
+
expect(isReviewCycleLive(task)).toBe(true);
|
|
641
|
+
task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
642
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("a live await_reviewers cycle is left intact when the guard blocks (no finalize)", () => {
|
|
646
|
+
const task = makeActiveTask();
|
|
647
|
+
task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 2 };
|
|
648
|
+
// The menu action returns early on isReviewCycleLive WITHOUT calling
|
|
649
|
+
// finalizeReviewCycle, so the running cycle survives a second selection.
|
|
650
|
+
expect(isReviewCycleLive(task)).toBe(true);
|
|
651
|
+
expect(task.state.reviewCycle).toEqual({ kind: "auto", step: "await_reviewers", pass: 2 });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("finalizeReviewCycle only clears a completed (non-live) cycle for the next pass", () => {
|
|
655
|
+
const task = makeActiveTask();
|
|
656
|
+
task.dir = mkdtempSync(join(tmpdir(), "pp-review-"));
|
|
657
|
+
task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
658
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
659
|
+
finalizeReviewCycle(task);
|
|
660
|
+
expect(task.state.reviewCycle).toBeNull();
|
|
661
|
+
rmSync(task.dir, { recursive: true, force: true });
|
|
662
|
+
});
|
|
663
|
+
});
|