@ilya-lesikov/pi-pi 0.7.0 → 0.8.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/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +5 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +176 -43
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +107 -12
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +151 -2
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +8 -0
- package/extensions/orchestrator/phases/review.ts +4 -3
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.ts +168 -54
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -161,14 +161,15 @@ Supported actions and payloads:
|
|
|
161
161
|
- `annotate-last`: `{ markdown? }`
|
|
162
162
|
- `archive`: `{ customPlanPath? }`
|
|
163
163
|
|
|
164
|
-
Plan review
|
|
164
|
+
Plan review and code review are asynchronous:
|
|
165
165
|
|
|
166
|
-
- callers send `plannotator:request` with action `plan-review`
|
|
167
|
-
- Plannotator
|
|
168
|
-
- when the human approves or rejects in the browser, Plannotator emits `plannotator:review-result` with `{ reviewId, approved, feedback, savedPath?, agentSwitch?, permissionMode? }`
|
|
166
|
+
- callers send `plannotator:request` with action `plan-review` or `code-review`
|
|
167
|
+
- Plannotator immediately responds with `{ status: "handled", result: { status: "pending", reviewId } }` — for `code-review` this ack is emitted BEFORE the (potentially slow) browser session and PR/diff/worktree preparation start, so callers can detect an absent extension within the short availability window without waiting for the review itself
|
|
168
|
+
- when the human approves or rejects in the browser (or closes the window), Plannotator emits `plannotator:review-result` with `{ reviewId, approved, feedback, savedPath?, agentSwitch?, permissionMode? }`
|
|
169
|
+
- if `code-review` startup/preparation fails after the pending ack, Plannotator emits `plannotator:review-result` with `{ reviewId, approved: false, error }` so the failure is distinguishable from a legitimate user rejection
|
|
169
170
|
- callers can query `review-status` with the same `reviewId` to recover from startup races or session restarts
|
|
170
171
|
|
|
171
|
-
The
|
|
172
|
+
The remaining shared actions (`annotate`, `annotate-last`, `archive`, `review-status`) are synchronous request/response flows. Payloads are intentionally minimal and only include fields the shared implementation actually uses.
|
|
172
173
|
|
|
173
174
|
### Markdown annotation
|
|
174
175
|
|
|
@@ -59,6 +59,19 @@ export interface PlanReviewBrowserSession extends BrowserDecisionSession<PlanRev
|
|
|
59
59
|
onDecision: (listener: (result: PlanReviewDecision) => void | Promise<void>) => () => void;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export interface CodeReviewDecision {
|
|
63
|
+
approved: boolean;
|
|
64
|
+
feedback?: string;
|
|
65
|
+
annotations?: unknown[];
|
|
66
|
+
agentSwitch?: string;
|
|
67
|
+
exit?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CodeReviewBrowserSession extends BrowserDecisionSession<CodeReviewDecision> {
|
|
71
|
+
reviewId: string;
|
|
72
|
+
onDecision: (listener: (result: CodeReviewDecision) => void | Promise<void>) => () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
63
76
|
let planHtmlContent = "";
|
|
64
77
|
let reviewHtmlContent = "";
|
|
@@ -230,15 +243,7 @@ export async function openCodeReview(
|
|
|
230
243
|
export async function startCodeReviewBrowserSession(
|
|
231
244
|
ctx: ExtensionContext,
|
|
232
245
|
options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {},
|
|
233
|
-
): Promise<
|
|
234
|
-
BrowserDecisionSession<{
|
|
235
|
-
approved: boolean;
|
|
236
|
-
feedback?: string;
|
|
237
|
-
annotations?: unknown[];
|
|
238
|
-
agentSwitch?: string;
|
|
239
|
-
exit?: boolean;
|
|
240
|
-
}>
|
|
241
|
-
> {
|
|
246
|
+
): Promise<CodeReviewBrowserSession> {
|
|
242
247
|
if (!ctx.hasUI || !reviewHtmlContent) {
|
|
243
248
|
throw new Error("Plannotator code review browser is unavailable in this session.");
|
|
244
249
|
}
|
|
@@ -477,9 +482,19 @@ export async function startCodeReviewBrowserSession(
|
|
|
477
482
|
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
|
|
478
483
|
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
|
|
479
484
|
onCleanup: worktreeCleanup,
|
|
485
|
+
repoCwd: agentCwd ?? options.cwd ?? ctx.cwd,
|
|
480
486
|
});
|
|
481
487
|
|
|
482
|
-
|
|
488
|
+
const session = startBrowserDecisionSession(server, ctx, server.waitForDecision);
|
|
489
|
+
server.onDecision(() => {
|
|
490
|
+
setTimeout(() => session.stop(), 1500);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
...session,
|
|
495
|
+
reviewId: server.reviewId,
|
|
496
|
+
onDecision: server.onDecision,
|
|
497
|
+
};
|
|
483
498
|
}
|
|
484
499
|
|
|
485
500
|
export async function openMarkdownAnnotation(
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, test, mock, beforeEach } from "bun:test";
|
|
2
|
+
|
|
3
|
+
type DecisionListener = (result: { approved: boolean; feedback?: string; agentSwitch?: string }) => void;
|
|
4
|
+
|
|
5
|
+
let sessionBehavior: {
|
|
6
|
+
throwOnStart?: Error;
|
|
7
|
+
decision?: { approved: boolean; feedback?: string };
|
|
8
|
+
resolveWaitImmediately?: boolean;
|
|
9
|
+
} = {};
|
|
10
|
+
let startCalls = 0;
|
|
11
|
+
let decisionListeners: DecisionListener[] = [];
|
|
12
|
+
|
|
13
|
+
mock.module("./plannotator-browser.js", () => ({
|
|
14
|
+
getLastAssistantMessageText: () => "",
|
|
15
|
+
getStartupErrorMessage: (err: unknown) => (err instanceof Error ? err.message : "Unknown error"),
|
|
16
|
+
hasPlanBrowserHtml: () => true,
|
|
17
|
+
hasReviewBrowserHtml: () => true,
|
|
18
|
+
shouldUseLocalPrCheckout: (options: { useLocal?: boolean }) => options.useLocal !== false,
|
|
19
|
+
openArchiveBrowserAction: async () => ({ opened: false }),
|
|
20
|
+
openCodeReview: async () => ({ approved: false }),
|
|
21
|
+
openPlanReviewBrowser: async () => ({ approved: false }),
|
|
22
|
+
openLastMessageAnnotation: async () => ({ feedback: "" }),
|
|
23
|
+
openMarkdownAnnotation: async () => ({ feedback: "" }),
|
|
24
|
+
startCodeReviewBrowserSession: async () => {
|
|
25
|
+
startCalls += 1;
|
|
26
|
+
if (sessionBehavior.throwOnStart) throw sessionBehavior.throwOnStart;
|
|
27
|
+
return {
|
|
28
|
+
url: "http://localhost:0",
|
|
29
|
+
reviewId: "server-review-id",
|
|
30
|
+
onDecision: (listener: DecisionListener) => {
|
|
31
|
+
decisionListeners.push(listener);
|
|
32
|
+
return () => {};
|
|
33
|
+
},
|
|
34
|
+
waitForDecision: () =>
|
|
35
|
+
new Promise((resolve) => {
|
|
36
|
+
if (sessionBehavior.resolveWaitImmediately && sessionBehavior.decision) {
|
|
37
|
+
resolve(sessionBehavior.decision as any);
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
stop: () => {},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
startLastMessageAnnotationSession: async () => ({}),
|
|
44
|
+
startMarkdownAnnotationSession: async () => ({}),
|
|
45
|
+
startPlanReviewBrowserSession: async () => ({}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const { registerPlannotatorEventListeners, PLANNOTATOR_REQUEST_CHANNEL, PLANNOTATOR_REVIEW_RESULT_CHANNEL } =
|
|
49
|
+
await import("./plannotator-events.js");
|
|
50
|
+
|
|
51
|
+
interface FakePi {
|
|
52
|
+
on: (event: string, handler: (event: any, ctx: any) => void | Promise<void>) => void;
|
|
53
|
+
events: {
|
|
54
|
+
on: (channel: string, handler: (data: any) => void) => () => void;
|
|
55
|
+
emit: (channel: string, data: any) => void;
|
|
56
|
+
_handlers: Record<string, Array<(data: any) => void>>;
|
|
57
|
+
_sessionStart?: (event: any, ctx: any) => void | Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeFakePi(): FakePi {
|
|
62
|
+
const handlers: Record<string, Array<(data: any) => void>> = {};
|
|
63
|
+
const pi: FakePi = {
|
|
64
|
+
on: (event, handler) => {
|
|
65
|
+
if (event === "session_start") pi.events._sessionStart = handler;
|
|
66
|
+
},
|
|
67
|
+
events: {
|
|
68
|
+
_handlers: handlers,
|
|
69
|
+
on: (channel, handler) => {
|
|
70
|
+
(handlers[channel] ??= []).push(handler);
|
|
71
|
+
return () => {
|
|
72
|
+
handlers[channel] = (handlers[channel] ?? []).filter((h) => h !== handler);
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
emit: (channel, data) => {
|
|
76
|
+
for (const h of handlers[channel] ?? []) h(data);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return pi;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function emitCodeReviewRequest(pi: FakePi): Promise<{
|
|
84
|
+
response: any;
|
|
85
|
+
results: any[];
|
|
86
|
+
}> {
|
|
87
|
+
const results: any[] = [];
|
|
88
|
+
pi.events.on(PLANNOTATOR_REVIEW_RESULT_CHANNEL, (data) => results.push(data));
|
|
89
|
+
|
|
90
|
+
let response: any;
|
|
91
|
+
pi.events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
|
|
92
|
+
requestId: "req-1",
|
|
93
|
+
action: "code-review",
|
|
94
|
+
payload: { cwd: "/tmp/repo" },
|
|
95
|
+
respond: (r: any) => {
|
|
96
|
+
response = r;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
// Let the synchronous respond + microtasks settle.
|
|
100
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
101
|
+
return { response, results };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
sessionBehavior = {};
|
|
106
|
+
startCalls = 0;
|
|
107
|
+
decisionListeners = [];
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("code-review two-phase handler", () => {
|
|
111
|
+
test("acks pending with a reviewId synchronously, before any heavy prep resolves", async () => {
|
|
112
|
+
const pi = makeFakePi();
|
|
113
|
+
registerPlannotatorEventListeners(pi as any);
|
|
114
|
+
await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
|
|
115
|
+
|
|
116
|
+
let respondedSynchronously = false;
|
|
117
|
+
let ackReviewId: string | undefined;
|
|
118
|
+
pi.events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
|
|
119
|
+
requestId: "req-1",
|
|
120
|
+
action: "code-review",
|
|
121
|
+
payload: { cwd: "/tmp/repo" },
|
|
122
|
+
respond: (r: any) => {
|
|
123
|
+
respondedSynchronously = true;
|
|
124
|
+
ackReviewId = r?.result?.reviewId;
|
|
125
|
+
expect(r.status).toBe("handled");
|
|
126
|
+
expect(r.result.status).toBe("pending");
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// The ack must fire in the same tick as the request handler's first
|
|
131
|
+
// awaited boundary — before startCodeReviewBrowserSession is invoked.
|
|
132
|
+
expect(respondedSynchronously).toBe(true);
|
|
133
|
+
expect(typeof ackReviewId).toBe("string");
|
|
134
|
+
expect(ackReviewId!.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("emits a review-result on the pending reviewId when the user decides", async () => {
|
|
138
|
+
const pi = makeFakePi();
|
|
139
|
+
registerPlannotatorEventListeners(pi as any);
|
|
140
|
+
await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
|
|
141
|
+
|
|
142
|
+
const { response, results } = await emitCodeReviewRequest(pi);
|
|
143
|
+
const reviewId = response.result.reviewId;
|
|
144
|
+
|
|
145
|
+
expect(startCalls).toBe(1);
|
|
146
|
+
expect(decisionListeners.length).toBe(1);
|
|
147
|
+
|
|
148
|
+
decisionListeners[0]({ approved: true, feedback: "looks good" });
|
|
149
|
+
|
|
150
|
+
expect(results.length).toBe(1);
|
|
151
|
+
expect(results[0].reviewId).toBe(reviewId);
|
|
152
|
+
expect(results[0].approved).toBe(true);
|
|
153
|
+
expect(results[0].feedback).toBe("looks good");
|
|
154
|
+
expect(results[0].error).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("emits a review-result with an error when startup/prep fails after the pending ack", async () => {
|
|
158
|
+
sessionBehavior = { throwOnStart: new Error("worktree prep failed") };
|
|
159
|
+
const pi = makeFakePi();
|
|
160
|
+
registerPlannotatorEventListeners(pi as any);
|
|
161
|
+
await pi.events._sessionStart?.({}, { cwd: "/tmp/repo", hasUI: true });
|
|
162
|
+
|
|
163
|
+
const { response, results } = await emitCodeReviewRequest(pi);
|
|
164
|
+
const reviewId = response.result.reviewId;
|
|
165
|
+
|
|
166
|
+
expect(response.result.status).toBe("pending");
|
|
167
|
+
expect(results.length).toBe(1);
|
|
168
|
+
expect(results[0].reviewId).toBe(reviewId);
|
|
169
|
+
expect(results[0].approved).toBe(false);
|
|
170
|
+
expect(results[0].error).toBe("worktree prep failed");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
getLastAssistantMessageText,
|
|
9
9
|
getStartupErrorMessage,
|
|
10
10
|
openArchiveBrowserAction,
|
|
11
|
-
openCodeReview,
|
|
12
11
|
openLastMessageAnnotation,
|
|
13
12
|
openMarkdownAnnotation,
|
|
14
13
|
startCodeReviewBrowserSession,
|
|
@@ -74,6 +73,12 @@ export interface PlannotatorReviewResultEvent {
|
|
|
74
73
|
savedPath?: string;
|
|
75
74
|
agentSwitch?: string;
|
|
76
75
|
permissionMode?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Set when the review could not be produced (e.g. code-review startup/prep
|
|
78
|
+
* failed after the pending ack). Distinguishes a failure from a legitimate
|
|
79
|
+
* user rejection (`approved: false` with feedback).
|
|
80
|
+
*/
|
|
81
|
+
error?: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export interface PlannotatorReviewStatusPayload {
|
|
@@ -101,6 +106,11 @@ export interface PlannotatorCodeReviewResult {
|
|
|
101
106
|
agentSwitch?: string;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
export interface PlannotatorCodeReviewStartResult {
|
|
110
|
+
status: "pending";
|
|
111
|
+
reviewId: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
export interface PlannotatorAnnotatePayload {
|
|
105
115
|
filePath: string;
|
|
106
116
|
markdown?: string;
|
|
@@ -129,7 +139,7 @@ export interface PlannotatorArchiveResult {
|
|
|
129
139
|
export type PlannotatorRequestMap = {
|
|
130
140
|
"plan-review": PlannotatorRequestBase<"plan-review", PlannotatorPlanReviewPayload, PlannotatorPlanReviewStartResult>;
|
|
131
141
|
"review-status": PlannotatorRequestBase<"review-status", PlannotatorReviewStatusPayload, PlannotatorReviewStatusResult>;
|
|
132
|
-
"code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload,
|
|
142
|
+
"code-review": PlannotatorRequestBase<"code-review", PlannotatorCodeReviewPayload, PlannotatorCodeReviewStartResult>;
|
|
133
143
|
annotate: PlannotatorRequestBase<"annotate", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
|
|
134
144
|
"annotate-last": PlannotatorRequestBase<"annotate-last", PlannotatorAnnotatePayload, PlannotatorAnnotationResult>;
|
|
135
145
|
archive: PlannotatorRequestBase<"archive", PlannotatorArchivePayload, PlannotatorArchiveResult>;
|
|
@@ -138,7 +148,7 @@ export type PlannotatorRequest = PlannotatorRequestMap[PlannotatorAction];
|
|
|
138
148
|
export type PlannotatorResponseMap = {
|
|
139
149
|
"plan-review": PlannotatorResponse<PlannotatorPlanReviewStartResult>;
|
|
140
150
|
"review-status": PlannotatorResponse<PlannotatorReviewStatusResult>;
|
|
141
|
-
"code-review": PlannotatorResponse<
|
|
151
|
+
"code-review": PlannotatorResponse<PlannotatorCodeReviewStartResult>;
|
|
142
152
|
annotate: PlannotatorResponse<PlannotatorAnnotationResult>;
|
|
143
153
|
"annotate-last": PlannotatorResponse<PlannotatorAnnotationResult>;
|
|
144
154
|
archive: PlannotatorResponse<PlannotatorArchiveResult>;
|
|
@@ -263,15 +273,43 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
|
|
|
263
273
|
return;
|
|
264
274
|
}
|
|
265
275
|
case "code-review": {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
276
|
+
const reviewId = crypto.randomUUID();
|
|
277
|
+
setStoredReviewStatus(reviewId, { status: "pending" });
|
|
278
|
+
request.respond({ status: "handled", result: { status: "pending", reviewId } });
|
|
279
|
+
|
|
280
|
+
const payload = request.payload;
|
|
281
|
+
void (async () => {
|
|
282
|
+
try {
|
|
283
|
+
const session = await startCodeReviewBrowserSession(ctx, {
|
|
284
|
+
cwd: payload?.cwd,
|
|
285
|
+
defaultBranch: payload?.defaultBranch,
|
|
286
|
+
diffType: payload?.diffType,
|
|
287
|
+
vcsType: payload?.vcsType,
|
|
288
|
+
useLocal: payload?.useLocal,
|
|
289
|
+
prUrl: payload?.prUrl,
|
|
290
|
+
});
|
|
291
|
+
session.onDecision((result) => {
|
|
292
|
+
const reviewResult = {
|
|
293
|
+
reviewId,
|
|
294
|
+
approved: result.approved,
|
|
295
|
+
feedback: result.feedback,
|
|
296
|
+
agentSwitch: result.agentSwitch,
|
|
297
|
+
} satisfies PlannotatorReviewResultEvent;
|
|
298
|
+
setStoredReviewStatus(reviewId, { status: "completed", ...reviewResult });
|
|
299
|
+
pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
|
|
300
|
+
});
|
|
301
|
+
await session.waitForDecision();
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const message = getStartupErrorMessage(err);
|
|
304
|
+
const reviewResult = {
|
|
305
|
+
reviewId,
|
|
306
|
+
approved: false,
|
|
307
|
+
error: message,
|
|
308
|
+
} satisfies PlannotatorReviewResultEvent;
|
|
309
|
+
setStoredReviewStatus(reviewId, { status: "completed", ...reviewResult });
|
|
310
|
+
pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult);
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
275
313
|
return;
|
|
276
314
|
}
|
|
277
315
|
case "annotate": {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getRepoInfo } from "./project";
|
|
7
|
+
|
|
8
|
+
const cleanup: string[] = [];
|
|
9
|
+
|
|
10
|
+
function makeRepo(branch: string): string {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "plannotator-project-test-"));
|
|
12
|
+
cleanup.push(dir);
|
|
13
|
+
const run = (cmd: string) => execSync(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] });
|
|
14
|
+
run(`git init -q -b ${branch}`);
|
|
15
|
+
run("git config user.email test@test.local");
|
|
16
|
+
run("git config user.name test");
|
|
17
|
+
run("git remote add origin https://github.com/acme/widget.git");
|
|
18
|
+
run("git commit -q --allow-empty -m init");
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
for (const dir of cleanup) {
|
|
24
|
+
try {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("getRepoInfo(cwd)", () => {
|
|
31
|
+
test("reports the repo at the given cwd, not process.cwd()", () => {
|
|
32
|
+
const repo = makeRepo("feature-x");
|
|
33
|
+
const info = getRepoInfo(repo);
|
|
34
|
+
expect(info).not.toBeNull();
|
|
35
|
+
expect(info!.display).toBe("acme/widget");
|
|
36
|
+
expect(info!.branch).toBe("feature-x");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("two different cwds resolve to their own repos", () => {
|
|
40
|
+
const a = makeRepo("branch-a");
|
|
41
|
+
const b = makeRepo("branch-b");
|
|
42
|
+
expect(getRepoInfo(a)!.branch).toBe("branch-a");
|
|
43
|
+
expect(getRepoInfo(b)!.branch).toBe("branch-b");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -9,11 +9,12 @@ import { sanitizeTag } from "../generated/project.js";
|
|
|
9
9
|
import { parseRemoteUrl, getDirName } from "../generated/repo.js";
|
|
10
10
|
|
|
11
11
|
/** Run a git command and return stdout (empty string on error). */
|
|
12
|
-
function git(cmd: string): string {
|
|
12
|
+
function git(cmd: string, cwd?: string): string {
|
|
13
13
|
try {
|
|
14
14
|
return execSync(`git ${cmd}`, {
|
|
15
15
|
encoding: "utf-8",
|
|
16
16
|
stdio: ["pipe", "pipe", "pipe"],
|
|
17
|
+
cwd,
|
|
17
18
|
}).trim();
|
|
18
19
|
} catch {
|
|
19
20
|
return "";
|
|
@@ -39,23 +40,23 @@ export function detectProjectName(): string {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
export function getRepoInfo(): { display: string; branch?: string } | null {
|
|
43
|
-
const branch = git("rev-parse --abbrev-ref HEAD");
|
|
43
|
+
export function getRepoInfo(cwd?: string): { display: string; branch?: string } | null {
|
|
44
|
+
const branch = git("rev-parse --abbrev-ref HEAD", cwd);
|
|
44
45
|
const safeBranch = branch && branch !== "HEAD" ? branch : undefined;
|
|
45
46
|
|
|
46
|
-
const originUrl = git("remote get-url origin");
|
|
47
|
+
const originUrl = git("remote get-url origin", cwd);
|
|
47
48
|
const orgRepo = parseRemoteUrl(originUrl);
|
|
48
49
|
if (orgRepo) {
|
|
49
50
|
return { display: orgRepo, branch: safeBranch };
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
const topLevel = git("rev-parse --show-toplevel");
|
|
53
|
+
const topLevel = git("rev-parse --show-toplevel", cwd);
|
|
53
54
|
const repoName = getDirName(topLevel);
|
|
54
55
|
if (repoName) {
|
|
55
56
|
return { display: repoName, branch: safeBranch };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
const cwdName = getDirName(process.cwd());
|
|
59
|
+
const cwdName = getDirName(cwd ?? process.cwd());
|
|
59
60
|
if (cwdName) {
|
|
60
61
|
return { display: cwdName };
|
|
61
62
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
3
4
|
import { createServer } from "node:http";
|
|
4
5
|
import os from "node:os";
|
|
@@ -161,18 +162,22 @@ function detectWSL(): boolean {
|
|
|
161
162
|
return false;
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
export interface ReviewDecision {
|
|
166
|
+
approved: boolean;
|
|
167
|
+
feedback: string;
|
|
168
|
+
annotations: unknown[];
|
|
169
|
+
agentSwitch?: string;
|
|
170
|
+
exit?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
164
173
|
export interface ReviewServerResult {
|
|
174
|
+
reviewId: string;
|
|
165
175
|
port: number;
|
|
166
176
|
portSource: "env" | "remote-default" | "random";
|
|
167
177
|
url: string;
|
|
168
178
|
isRemote: boolean;
|
|
169
|
-
waitForDecision: () => Promise<
|
|
170
|
-
|
|
171
|
-
feedback: string;
|
|
172
|
-
annotations: unknown[];
|
|
173
|
-
agentSwitch?: string;
|
|
174
|
-
exit?: boolean;
|
|
175
|
-
}>;
|
|
179
|
+
waitForDecision: () => Promise<ReviewDecision>;
|
|
180
|
+
onDecision: (listener: (result: ReviewDecision) => void | Promise<void>) => () => void;
|
|
176
181
|
stop: () => void;
|
|
177
182
|
}
|
|
178
183
|
|
|
@@ -206,6 +211,8 @@ export async function startReviewServer(options: {
|
|
|
206
211
|
onCleanup?: () => void | Promise<void>;
|
|
207
212
|
/** Called when server starts with the URL, remote status, and port */
|
|
208
213
|
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
|
214
|
+
/** Working directory of the reviewed repo, used for repo-info (title/header) detection. */
|
|
215
|
+
repoCwd?: string;
|
|
209
216
|
}): Promise<ReviewServerResult> {
|
|
210
217
|
const gitUser = detectGitUser();
|
|
211
218
|
let draftKey = contentHash(options.rawPatch);
|
|
@@ -266,7 +273,7 @@ export async function startReviewServer(options: {
|
|
|
266
273
|
}
|
|
267
274
|
: workspace
|
|
268
275
|
? { display: basename(workspace.root), branch: "Workspace" }
|
|
269
|
-
: getRepoInfo();
|
|
276
|
+
: getRepoInfo(options.repoCwd);
|
|
270
277
|
const editorAnnotations = createEditorAnnotationHandler();
|
|
271
278
|
const externalAnnotations = createExternalAnnotationHandler("review");
|
|
272
279
|
|
|
@@ -538,22 +545,24 @@ export async function startReviewServer(options: {
|
|
|
538
545
|
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
|
539
546
|
const pasteApiUrl =
|
|
540
547
|
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
exit?: boolean;
|
|
547
|
-
}) => void;
|
|
548
|
-
const decisionPromise = new Promise<{
|
|
549
|
-
approved: boolean;
|
|
550
|
-
feedback: string;
|
|
551
|
-
annotations: unknown[];
|
|
552
|
-
agentSwitch?: string;
|
|
553
|
-
exit?: boolean;
|
|
554
|
-
}>((r) => {
|
|
548
|
+
const reviewId = randomUUID();
|
|
549
|
+
let resolveDecision!: (result: ReviewDecision) => void;
|
|
550
|
+
const decisionListeners = new Set<(result: ReviewDecision) => void | Promise<void>>();
|
|
551
|
+
let decisionSettled = false;
|
|
552
|
+
const decisionPromise = new Promise<ReviewDecision>((r) => {
|
|
555
553
|
resolveDecision = r;
|
|
556
554
|
});
|
|
555
|
+
const publishDecision = (result: ReviewDecision): boolean => {
|
|
556
|
+
if (decisionSettled) return false;
|
|
557
|
+
decisionSettled = true;
|
|
558
|
+
resolveDecision(result);
|
|
559
|
+
for (const listener of decisionListeners) {
|
|
560
|
+
Promise.resolve(listener(result)).catch((error) => {
|
|
561
|
+
console.error("[Code Review] Decision listener failed:", error);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
565
|
+
};
|
|
557
566
|
|
|
558
567
|
const aiRuntime = await createPiAIRuntime({ getCwd: resolveAgentCwd });
|
|
559
568
|
|
|
@@ -1175,13 +1184,13 @@ export async function startReviewServer(options: {
|
|
|
1175
1184
|
return;
|
|
1176
1185
|
} else if (url.pathname === "/api/exit" && req.method === "POST") {
|
|
1177
1186
|
deleteDraft(draftKey);
|
|
1178
|
-
|
|
1179
|
-
json(res, { ok: true });
|
|
1187
|
+
const duplicate = !publishDecision({ approved: false, feedback: '', annotations: [], exit: true });
|
|
1188
|
+
json(res, { ok: true, ...(duplicate && { duplicate: true }) });
|
|
1180
1189
|
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
|
|
1181
1190
|
try {
|
|
1182
1191
|
const body = await parseBody(req);
|
|
1183
1192
|
deleteDraft(draftKey);
|
|
1184
|
-
|
|
1193
|
+
publishDecision({
|
|
1185
1194
|
approved: (body.approved as boolean) ?? false,
|
|
1186
1195
|
feedback: (body.feedback as string) || "",
|
|
1187
1196
|
annotations: (body.annotations as unknown[]) || [],
|
|
@@ -1207,11 +1216,18 @@ export async function startReviewServer(options: {
|
|
|
1207
1216
|
}
|
|
1208
1217
|
|
|
1209
1218
|
return {
|
|
1219
|
+
reviewId,
|
|
1210
1220
|
port,
|
|
1211
1221
|
portSource,
|
|
1212
1222
|
url: serverUrl,
|
|
1213
1223
|
isRemote,
|
|
1214
1224
|
waitForDecision: () => decisionPromise,
|
|
1225
|
+
onDecision: (listener) => {
|
|
1226
|
+
decisionListeners.add(listener);
|
|
1227
|
+
return () => {
|
|
1228
|
+
decisionListeners.delete(listener);
|
|
1229
|
+
};
|
|
1230
|
+
},
|
|
1215
1231
|
stop: () => {
|
|
1216
1232
|
process.removeListener("exit", exitHandler);
|
|
1217
1233
|
agentJobs.killAll();
|
|
@@ -123,6 +123,10 @@ export class AgentManager {
|
|
|
123
123
|
record.status = "running";
|
|
124
124
|
record.startedAt = Date.now();
|
|
125
125
|
if (options.toolCallId && !record.toolCallId) record.toolCallId = options.toolCallId;
|
|
126
|
+
// Reset first-progress flags: a re-started (e.g. dequeued) record must be able
|
|
127
|
+
// to emit first_tool/first_turn again for its fresh run.
|
|
128
|
+
record.firstToolEmitted = false;
|
|
129
|
+
record.firstTurnEmitted = false;
|
|
126
130
|
if (options.isBackground) this.runningBackground++;
|
|
127
131
|
this.onStart?.(record);
|
|
128
132
|
|
|
@@ -153,9 +157,38 @@ export class AgentManager {
|
|
|
153
157
|
signal: record.abortController!.signal,
|
|
154
158
|
onToolActivity: (activity) => {
|
|
155
159
|
if (activity.type === "end") record.toolUses++;
|
|
160
|
+
// Emit first_tool once per run from this single choke point, so ALL spawn
|
|
161
|
+
// paths (RPC panels, Agent-tool background, foreground) report it — not
|
|
162
|
+
// just the Agent tool's background branch. Panels spawn via RPC and were
|
|
163
|
+
// previously silent, making it impossible to tell if a reviewer actually
|
|
164
|
+
// investigated (ran tools) or wrote from context alone.
|
|
165
|
+
if (activity.type === "start" && !record.firstToolEmitted) {
|
|
166
|
+
record.firstToolEmitted = true;
|
|
167
|
+
try {
|
|
168
|
+
pi.events.emit("subagents:first_tool", {
|
|
169
|
+
id,
|
|
170
|
+
type,
|
|
171
|
+
description: options.description,
|
|
172
|
+
toolName: activity.toolName,
|
|
173
|
+
});
|
|
174
|
+
} catch { /* ignore */ }
|
|
175
|
+
}
|
|
156
176
|
options.onToolActivity?.(activity);
|
|
157
177
|
},
|
|
158
|
-
onTurnEnd:
|
|
178
|
+
onTurnEnd: (turnCount) => {
|
|
179
|
+
if (!record.firstTurnEmitted) {
|
|
180
|
+
record.firstTurnEmitted = true;
|
|
181
|
+
try {
|
|
182
|
+
pi.events.emit("subagents:first_turn", {
|
|
183
|
+
id,
|
|
184
|
+
type,
|
|
185
|
+
description: options.description,
|
|
186
|
+
turnCount,
|
|
187
|
+
});
|
|
188
|
+
} catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
options.onTurnEnd?.(turnCount);
|
|
191
|
+
},
|
|
159
192
|
onTextDelta: options.onTextDelta,
|
|
160
193
|
validateCompletion: options.validateCompletion,
|
|
161
194
|
maxValidationRetries: options.maxValidationRetries,
|