pi-pr-review 1.10.2 → 1.10.3
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +2 -0
- package/extensions/review-table.ts +67 -6
- package/lib/pr-review-loop.ts +43 -1
- package/package.json +1 -1
- package/tests/pr-review-extension-lifecycle.test.ts +132 -0
- package/tests/pr-review-loop.test.ts +23 -0
- package/tests/pr-review-publish.test.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.3](https://github.com/10ego/pi-pr-review/compare/v1.10.2...v1.10.3) (2026-07-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **publish:** retry invalid review output once ([#27](https://github.com/10ego/pi-pr-review/issues/27)) ([9689505](https://github.com/10ego/pi-pr-review/commit/968950579c4eeec0ac264dccf3bda9b1833df160))
|
|
9
|
+
|
|
3
10
|
## [1.10.2](https://github.com/10ego/pi-pr-review/compare/v1.10.1...v1.10.2) (2026-07-15)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -184,6 +184,8 @@ Publishing is off by default.
|
|
|
184
184
|
|
|
185
185
|
The extension—not the model—owns publishing. It creates one formal review with the event hardcoded to `COMMENT`; it never submits `APPROVE` or `REQUEST_CHANGES`. Before writing, it verifies the current PR head, validates inline anchors, and checks for a review of the same head by the current GitHub identity.
|
|
186
186
|
|
|
187
|
+
If the agent's final review is not valid exact-contract JSON, the extension logs the validation reason and automatically asks the same agent to correct its completed output once, with all tools disabled and without changing the captured posting authority. A valid correction is cached and publication is attempted under the original flags/config. If that single correction is also invalid or attempts to call a tool, publication stops and reports the error instead of looping. Overlapping input cancels the correction, remains unqueued, and must be retried after the agent settles.
|
|
188
|
+
|
|
187
189
|
Closed or merged PRs use a body-only review. Open PRs attach eligible P0–P3 findings as inline comments and keep nits or off-diff findings in the review body. When multiple findings target the same diff anchor, the first is attached inline and later findings remain in the review body so publication neither fails nor drops review content.
|
|
188
190
|
|
|
189
191
|
After a review completes, you can directly ask the agent to “post the inline review” or “post it as an inline review.” The extension handles that request directly before an agent turn, selecting the latest cached review for an unnumbered request or the named PR in “publish the review for PR #123.” Only fresh interactive/RPC input can trigger this path; extension-generated, queued, or steering input cannot. The extension publishes only validated cached content, never replacement model-authored text, and never starts or reruns review agents. A direct request permits stale publication.
|
|
@@ -456,6 +456,8 @@ export default function registerReviewTable(
|
|
|
456
456
|
session?: CompletedReviewSessionIdentity;
|
|
457
457
|
}
|
|
458
458
|
| undefined;
|
|
459
|
+
let outputRepairAttempted = false;
|
|
460
|
+
let outputRepairCancelled = false;
|
|
459
461
|
const telemetryTracker = new ReviewTelemetryTracker();
|
|
460
462
|
const persistTelemetry = (completion: ReviewPerformanceTelemetry["completion"]) => {
|
|
461
463
|
const telemetry = telemetryTracker.finish(completion);
|
|
@@ -554,6 +556,8 @@ export default function registerReviewTable(
|
|
|
554
556
|
loopCoordinator.clear();
|
|
555
557
|
selfReviewCoordinator.clear();
|
|
556
558
|
pendingCompletion = undefined;
|
|
559
|
+
outputRepairAttempted = false;
|
|
560
|
+
outputRepairCancelled = false;
|
|
557
561
|
telemetryTracker.clear();
|
|
558
562
|
};
|
|
559
563
|
|
|
@@ -573,12 +577,19 @@ export default function registerReviewTable(
|
|
|
573
577
|
|
|
574
578
|
pi.on("agent_settled", () => {
|
|
575
579
|
selfReviewCoordinator.clear();
|
|
580
|
+
if (!outputRepairCancelled) return;
|
|
581
|
+
loopCoordinator.clear();
|
|
582
|
+
outputRepairAttempted = false;
|
|
583
|
+
outputRepairCancelled = false;
|
|
584
|
+
persistTelemetry("cleared");
|
|
576
585
|
});
|
|
577
586
|
|
|
578
587
|
pi.on("session_tree", (event, ctx) => {
|
|
579
588
|
loopCoordinator.clear();
|
|
580
589
|
selfReviewCoordinator.clear();
|
|
581
590
|
pendingCompletion = undefined;
|
|
591
|
+
outputRepairAttempted = false;
|
|
592
|
+
outputRepairCancelled = false;
|
|
582
593
|
restoreCompletedReviews(ctx);
|
|
583
594
|
telemetryTracker.clear();
|
|
584
595
|
const session = sessionIdentity(ctx);
|
|
@@ -595,6 +606,13 @@ export default function registerReviewTable(
|
|
|
595
606
|
// Any new input revokes the prior top-level task generation before it can
|
|
596
607
|
// authorize a replay or a queued/steering continuation.
|
|
597
608
|
selfReviewCoordinator.clear();
|
|
609
|
+
if (outputRepairAttempted) {
|
|
610
|
+
outputRepairCancelled = true;
|
|
611
|
+
ctx.abort();
|
|
612
|
+
ctx.ui.notify("PR review output correction was cancelled; overlapping input was not queued, so retry it when the agent settles", "warning");
|
|
613
|
+
return { action: "handled" as const };
|
|
614
|
+
}
|
|
615
|
+
outputRepairAttempted = false;
|
|
598
616
|
const source = event.source as ReviewLoopInputSource;
|
|
599
617
|
const directPublish = parseDirectPublishRequest(event.text);
|
|
600
618
|
if (
|
|
@@ -715,7 +733,20 @@ export default function registerReviewTable(
|
|
|
715
733
|
pi.on("message_end", async (event, ctx) => {
|
|
716
734
|
if (event.message.role !== "assistant") return;
|
|
717
735
|
const completion = classifyAssistantCompletion(event.message.stopReason, hasToolCall(event.message));
|
|
736
|
+
if (outputRepairCancelled) {
|
|
737
|
+
if (completion === "continue_tools") ctx.abort();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
718
740
|
if (completion === "continue_tools") {
|
|
741
|
+
if (outputRepairAttempted && loopCoordinator.peek()) {
|
|
742
|
+
ctx.ui.notify("PR review was not posted: automatic output correction attempted to call tools", "error");
|
|
743
|
+
ctx.abort();
|
|
744
|
+
loopCoordinator.clear();
|
|
745
|
+
outputRepairAttempted = false;
|
|
746
|
+
outputRepairCancelled = false;
|
|
747
|
+
persistTelemetry("cleared");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
719
750
|
const toolCalls = Array.isArray(event.message.content)
|
|
720
751
|
? event.message.content.filter((part) => part.type === "toolCall")
|
|
721
752
|
: [];
|
|
@@ -729,6 +760,8 @@ export default function registerReviewTable(
|
|
|
729
760
|
}
|
|
730
761
|
if (completion === "clear_invocation") {
|
|
731
762
|
loopCoordinator.clear();
|
|
763
|
+
outputRepairAttempted = false;
|
|
764
|
+
outputRepairCancelled = false;
|
|
732
765
|
persistTelemetry("cleared");
|
|
733
766
|
return;
|
|
734
767
|
}
|
|
@@ -744,15 +777,42 @@ export default function registerReviewTable(
|
|
|
744
777
|
return;
|
|
745
778
|
}
|
|
746
779
|
|
|
747
|
-
|
|
780
|
+
let publishable = active ? parsePublishableReview(text) : undefined;
|
|
781
|
+
if (active && !publishable?.review && !outputRepairAttempted) {
|
|
782
|
+
const error = publishable?.error ?? "final review JSON is invalid";
|
|
783
|
+
if (loopCoordinator.suspendToolsForRepair()) {
|
|
784
|
+
outputRepairAttempted = true;
|
|
785
|
+
outputRepairCancelled = false;
|
|
786
|
+
ctx.ui.notify(`PR review output is invalid (${error}); automatically correcting it once`, "warning");
|
|
787
|
+
pi.sendMessage(
|
|
788
|
+
{
|
|
789
|
+
customType: "pr-review-output-repair",
|
|
790
|
+
content: [
|
|
791
|
+
`The completed PR review could not be accepted because ${error}.`,
|
|
792
|
+
"This is the only automatic correction attempt. Do not call tools, redo the review, or change its substance.",
|
|
793
|
+
"Re-emit the same completed review as exactly one JSON object satisfying the required OUTPUT FORMAT. Emit no Markdown fences, prose, headings, or trailing text.",
|
|
794
|
+
].join(" "),
|
|
795
|
+
display: false,
|
|
796
|
+
details: { error, attempt: 1 },
|
|
797
|
+
},
|
|
798
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
799
|
+
);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
ctx.ui.notify("Automatic PR review output correction was skipped because tools could not be disabled", "warning");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// A valid response, or a failed single correction, consumes authority.
|
|
748
806
|
// Persist timing before publication so network/write latency is never coupled to review wall time.
|
|
749
807
|
const invocation = active ? loopCoordinator.consume() : undefined;
|
|
750
|
-
if (invocation) persistTelemetry("terminal_response");
|
|
751
|
-
if (!text.trim()) return;
|
|
752
|
-
const review = parseReview(text);
|
|
753
|
-
if (!review) return; // not a /pr-review JSON payload — leave untouched
|
|
754
808
|
if (invocation) {
|
|
755
|
-
|
|
809
|
+
outputRepairAttempted = false;
|
|
810
|
+
outputRepairCancelled = false;
|
|
811
|
+
persistTelemetry("terminal_response");
|
|
812
|
+
}
|
|
813
|
+
const review = text.trim() ? parseReview(text) : null;
|
|
814
|
+
if (invocation) {
|
|
815
|
+
publishable ??= parsePublishableReview(text);
|
|
756
816
|
let repository: RepositoryBinding | undefined;
|
|
757
817
|
let record: CompletedReviewRecord | undefined;
|
|
758
818
|
let session: CompletedReviewSessionIdentity | undefined;
|
|
@@ -775,6 +835,7 @@ export default function registerReviewTable(
|
|
|
775
835
|
}
|
|
776
836
|
pendingCompletion = { text, invocation, repository, record, session };
|
|
777
837
|
}
|
|
838
|
+
if (!review) return; // not a renderable /pr-review JSON payload — leave untouched
|
|
778
839
|
|
|
779
840
|
// Keep raw JSON for automation; only prettify for interactive terminals.
|
|
780
841
|
if (ctx.mode !== "tui") return;
|
package/lib/pr-review-loop.ts
CHANGED
|
@@ -76,11 +76,13 @@ export class ReviewLoopCoordinator {
|
|
|
76
76
|
private readonly invocationGate = new ReviewInvocationGate();
|
|
77
77
|
private readonly focusRegistry = new ReviewFocusRegistry();
|
|
78
78
|
private binding?: ReviewLoopBinding;
|
|
79
|
+
private suspendedTools?: string[];
|
|
79
80
|
private nextGeneration = 1;
|
|
80
81
|
|
|
81
82
|
constructor(private readonly pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">) {}
|
|
82
83
|
|
|
83
84
|
private setToolsEnabled(enabled: boolean): void {
|
|
85
|
+
if (this.suspendedTools !== undefined) return;
|
|
84
86
|
try {
|
|
85
87
|
const current = this.pi.getActiveTools();
|
|
86
88
|
const next = enabled
|
|
@@ -150,7 +152,9 @@ export class ReviewLoopCoordinator {
|
|
|
150
152
|
|
|
151
153
|
acquire(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): ReviewLoopLease | undefined {
|
|
152
154
|
const phase = this.invocationGate.phase();
|
|
153
|
-
if ((phase !== "reviewing" && phase !== "confirmed") || !this.binding)
|
|
155
|
+
if (this.suspendedTools !== undefined || (phase !== "reviewing" && phase !== "confirmed") || !this.binding) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
154
158
|
if (!sameBinding(this.binding, ctx) || this.binding.controller.signal.aborted) {
|
|
155
159
|
this.clear();
|
|
156
160
|
return undefined;
|
|
@@ -165,6 +169,7 @@ export class ReviewLoopCoordinator {
|
|
|
165
169
|
lease: ReviewLoopLease,
|
|
166
170
|
ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
|
|
167
171
|
): boolean {
|
|
172
|
+
if (this.suspendedTools !== undefined) return false;
|
|
168
173
|
const active = !!this.binding &&
|
|
169
174
|
this.binding.generation === lease.generation &&
|
|
170
175
|
!lease.signal.aborted &&
|
|
@@ -219,11 +224,48 @@ export class ReviewLoopCoordinator {
|
|
|
219
224
|
this.setToolsEnabled(false);
|
|
220
225
|
}
|
|
221
226
|
|
|
227
|
+
/** Hide every tool for a format-only repair turn while retaining invocation authority. */
|
|
228
|
+
suspendToolsForRepair(): boolean {
|
|
229
|
+
const phase = this.invocationGate.phase();
|
|
230
|
+
if (
|
|
231
|
+
this.suspendedTools !== undefined ||
|
|
232
|
+
!this.binding ||
|
|
233
|
+
(phase !== "reviewing" && phase !== "confirmed")
|
|
234
|
+
) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
let baseTools: string[] = [];
|
|
238
|
+
try {
|
|
239
|
+
baseTools = this.pi.getActiveTools().filter((name) => !REVIEW_LOOP_TOOL_SET.has(name));
|
|
240
|
+
this.suspendedTools = baseTools;
|
|
241
|
+
this.pi.setActiveTools([]);
|
|
242
|
+
return true;
|
|
243
|
+
} catch {
|
|
244
|
+
this.suspendedTools = undefined;
|
|
245
|
+
try {
|
|
246
|
+
this.pi.setActiveTools(baseTools);
|
|
247
|
+
} catch {
|
|
248
|
+
// Failure to restore remains fail-closed with no repair turn started.
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
private revokeBinding(): void {
|
|
223
255
|
const generation = this.binding?.generation;
|
|
224
256
|
if (generation !== undefined) this.focusRegistry.close(generation);
|
|
225
257
|
this.binding?.controller.abort();
|
|
226
258
|
this.binding = undefined;
|
|
259
|
+
const suspendedTools = this.suspendedTools;
|
|
260
|
+
this.suspendedTools = undefined;
|
|
261
|
+
if (suspendedTools !== undefined) {
|
|
262
|
+
try {
|
|
263
|
+
this.pi.setActiveTools(suspendedTools);
|
|
264
|
+
} catch {
|
|
265
|
+
// Keep tools fail-closed if the extension runtime is shutting down.
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
227
269
|
this.setToolsEnabled(false);
|
|
228
270
|
}
|
|
229
271
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.3",
|
|
4
4
|
"description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and safe COMMENT-only publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -69,6 +69,7 @@ interface Harness {
|
|
|
69
69
|
tools: Map<string, any>;
|
|
70
70
|
branch: any[];
|
|
71
71
|
notifications: string[];
|
|
72
|
+
sentMessages: Array<{ message: any; options: any }>;
|
|
72
73
|
activeTools(): string[];
|
|
73
74
|
abortCount(): number;
|
|
74
75
|
selfReviewCoordinator: SelfReviewPermitCoordinator;
|
|
@@ -156,6 +157,7 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
|
|
|
156
157
|
const tools = new Map<string, any>();
|
|
157
158
|
const branch = [...initialBranch];
|
|
158
159
|
const notifications: string[] = [];
|
|
160
|
+
const sentMessages: Array<{ message: any; options: any }> = [];
|
|
159
161
|
let activeTools = ["read", "bash", ...REVIEW_LOOP_TOOL_NAMES];
|
|
160
162
|
let aborts = 0;
|
|
161
163
|
let promptPath = ownPromptPath;
|
|
@@ -191,6 +193,9 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
|
|
|
191
193
|
appendEntry: (customType: string, data: unknown) => {
|
|
192
194
|
branch.push({ type: "custom", id: `custom-${nextId++}`, customType, data });
|
|
193
195
|
},
|
|
196
|
+
sendMessage: (message: any, options: any) => {
|
|
197
|
+
sentMessages.push({ message, options });
|
|
198
|
+
},
|
|
194
199
|
getActiveTools: () => [...activeTools],
|
|
195
200
|
setActiveTools: (next: string[]) => {
|
|
196
201
|
activeTools = [...next];
|
|
@@ -210,6 +215,7 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
|
|
|
210
215
|
tools,
|
|
211
216
|
branch,
|
|
212
217
|
notifications,
|
|
218
|
+
sentMessages,
|
|
213
219
|
activeTools: () => [...activeTools],
|
|
214
220
|
abortCount: () => aborts,
|
|
215
221
|
selfReviewCoordinator,
|
|
@@ -241,6 +247,132 @@ function persistedInlineReview(identity = session, allowStalePublish = true): an
|
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
describe("completed review extension lifecycle", () => {
|
|
250
|
+
test("automatically corrects invalid final JSON once and attempts publication", async () => {
|
|
251
|
+
const harness = createHarness();
|
|
252
|
+
await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
|
|
253
|
+
const wrapped = {
|
|
254
|
+
role: "assistant",
|
|
255
|
+
stopReason: "stop",
|
|
256
|
+
content: [{ type: "text", text: `\`\`\`json\n${JSON.stringify(review)}\n\`\`\`` }],
|
|
257
|
+
};
|
|
258
|
+
await harness.emit("message_end", { message: wrapped });
|
|
259
|
+
expect(harness.sentMessages).toHaveLength(1);
|
|
260
|
+
expect(harness.sentMessages[0]?.message).toMatchObject({
|
|
261
|
+
customType: "pr-review-output-repair",
|
|
262
|
+
display: false,
|
|
263
|
+
});
|
|
264
|
+
expect(harness.sentMessages[0]?.message.content).toContain("exactly one JSON object");
|
|
265
|
+
expect(harness.sentMessages[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
|
266
|
+
expect(harness.notifications.some((message) => message.includes("automatically correcting"))).toBeTrue();
|
|
267
|
+
expect(harness.activeTools()).toEqual([]);
|
|
268
|
+
|
|
269
|
+
harness.appendMessage(wrapped, "wrapped-review");
|
|
270
|
+
await harness.emit("turn_end", { message: wrapped, toolResults: [] });
|
|
271
|
+
expect(harness.notifications.some((message) => message.includes("was not posted"))).toBeFalse();
|
|
272
|
+
|
|
273
|
+
const payloadPath = installFakePublishingGh();
|
|
274
|
+
const corrected = {
|
|
275
|
+
role: "assistant",
|
|
276
|
+
stopReason: "stop",
|
|
277
|
+
content: [{ type: "text", text: JSON.stringify(review) }],
|
|
278
|
+
};
|
|
279
|
+
await harness.emit("message_end", { message: corrected });
|
|
280
|
+
harness.appendMessage(corrected, "corrected-review");
|
|
281
|
+
await harness.emit("turn_end", { message: corrected, toolResults: [] });
|
|
282
|
+
|
|
283
|
+
expect(harness.sentMessages).toHaveLength(1);
|
|
284
|
+
expect(harness.notifications.some((message) => message.includes("PR review posted"))).toBeTrue();
|
|
285
|
+
expect(JSON.parse(readFileSync(payloadPath, "utf8")).body).toContain("Checks lifecycle persistence");
|
|
286
|
+
expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("aborts and revokes repair authority when the correction attempts a tool call", async () => {
|
|
290
|
+
const harness = createHarness();
|
|
291
|
+
await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
|
|
292
|
+
const invalid = {
|
|
293
|
+
role: "assistant",
|
|
294
|
+
stopReason: "stop",
|
|
295
|
+
content: [{ type: "text", text: "not json" }],
|
|
296
|
+
};
|
|
297
|
+
await harness.emit("message_end", { message: invalid });
|
|
298
|
+
expect(harness.activeTools()).toEqual([]);
|
|
299
|
+
|
|
300
|
+
await harness.emit("message_end", {
|
|
301
|
+
message: {
|
|
302
|
+
role: "assistant",
|
|
303
|
+
stopReason: "toolUse",
|
|
304
|
+
content: [{ type: "toolCall", id: "repair-bash", name: "bash", arguments: { command: "echo unsafe" } }],
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(harness.abortCount()).toBe(1);
|
|
309
|
+
expect(harness.sentMessages).toHaveLength(1);
|
|
310
|
+
expect(harness.notifications.some((message) => message.includes("correction attempted to call tools"))).toBeTrue();
|
|
311
|
+
expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("keeps tools suspended until a cancelled repair definitively settles", async () => {
|
|
315
|
+
const harness = createHarness();
|
|
316
|
+
await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
|
|
317
|
+
const invalid = {
|
|
318
|
+
role: "assistant",
|
|
319
|
+
stopReason: "stop",
|
|
320
|
+
content: [{ type: "text", text: "not json" }],
|
|
321
|
+
};
|
|
322
|
+
await harness.emit("message_end", { message: invalid });
|
|
323
|
+
expect(harness.activeTools()).toEqual([]);
|
|
324
|
+
|
|
325
|
+
const overlap = await harness.emit("input", {
|
|
326
|
+
text: "do something unrelated",
|
|
327
|
+
source: "interactive",
|
|
328
|
+
streamingBehavior: "steer",
|
|
329
|
+
});
|
|
330
|
+
expect(overlap).toContainEqual({ action: "handled" });
|
|
331
|
+
expect(harness.abortCount()).toBe(1);
|
|
332
|
+
expect(harness.activeTools()).toEqual([]);
|
|
333
|
+
expect(harness.notifications.some((message) => message.includes("was not queued"))).toBeTrue();
|
|
334
|
+
|
|
335
|
+
const staleCorrection = {
|
|
336
|
+
role: "assistant",
|
|
337
|
+
stopReason: "stop",
|
|
338
|
+
content: [{ type: "text", text: JSON.stringify(review) }],
|
|
339
|
+
};
|
|
340
|
+
await harness.emit("message_end", { message: staleCorrection });
|
|
341
|
+
harness.appendMessage(staleCorrection, "stale-correction");
|
|
342
|
+
await harness.emit("turn_end", { message: staleCorrection, toolResults: [] });
|
|
343
|
+
expect(harness.notifications.some((message) => message.includes("PR review posted"))).toBeFalse();
|
|
344
|
+
expect(harness.activeTools()).toEqual([]);
|
|
345
|
+
|
|
346
|
+
await harness.emit("agent_settled", {});
|
|
347
|
+
expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("stops after one automatic correction attempt", async () => {
|
|
351
|
+
const harness = createHarness();
|
|
352
|
+
await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
|
|
353
|
+
const invalid = {
|
|
354
|
+
role: "assistant",
|
|
355
|
+
stopReason: "stop",
|
|
356
|
+
content: [{ type: "text", text: "not json" }],
|
|
357
|
+
};
|
|
358
|
+
await harness.emit("message_end", { message: invalid });
|
|
359
|
+
expect(harness.sentMessages).toHaveLength(1);
|
|
360
|
+
expect(harness.activeTools()).toEqual([]);
|
|
361
|
+
await harness.emit("turn_end", { message: invalid, toolResults: [] });
|
|
362
|
+
|
|
363
|
+
await harness.emit("message_end", { message: invalid });
|
|
364
|
+
harness.appendMessage(invalid, "invalid-retry");
|
|
365
|
+
await harness.emit("turn_end", { message: invalid, toolResults: [] });
|
|
366
|
+
|
|
367
|
+
expect(harness.sentMessages).toHaveLength(1);
|
|
368
|
+
expect(
|
|
369
|
+
harness.notifications.some((message) =>
|
|
370
|
+
message.includes("PR review was not posted: final response is not exactly one JSON object"),
|
|
371
|
+
),
|
|
372
|
+
).toBeTrue();
|
|
373
|
+
expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
|
|
374
|
+
});
|
|
375
|
+
|
|
244
376
|
test("persists a reference before publishing after Pi stores exact assistant JSON", async () => {
|
|
245
377
|
const harness = createHarness();
|
|
246
378
|
await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
|
|
@@ -55,6 +55,29 @@ describe("review-loop authority", () => {
|
|
|
55
55
|
expect(h.coordinator.acquire(h.ctx as any)).toBeDefined();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test("suspends every tool for output repair and restores only base tools", () => {
|
|
59
|
+
const h = harness();
|
|
60
|
+
h.coordinator.begin(parsePublishMode("/pr-review 7"), autoOff, "interactive", h.ctx as any);
|
|
61
|
+
const lease = h.coordinator.acquire(h.ctx as any)!;
|
|
62
|
+
|
|
63
|
+
expect(h.coordinator.suspendToolsForRepair()).toBeTrue();
|
|
64
|
+
expect(h.activeTools()).toEqual([]);
|
|
65
|
+
expect(h.coordinator.acquire(h.ctx as any)).toBeUndefined();
|
|
66
|
+
expect(h.coordinator.isLeaseActive(lease, h.ctx as any)).toBeFalse();
|
|
67
|
+
expect(lease.signal.aborted).toBeFalse();
|
|
68
|
+
expect(h.coordinator.suspendToolsForRepair()).toBeFalse();
|
|
69
|
+
|
|
70
|
+
expect(h.coordinator.consume()?.prNumber).toBe(7);
|
|
71
|
+
expect(lease.signal.aborted).toBeTrue();
|
|
72
|
+
expect(h.activeTools()).toEqual(["read", "bash"]);
|
|
73
|
+
|
|
74
|
+
h.coordinator.begin(parsePublishMode("/pr-review 8"), autoOff, "interactive", h.ctx as any);
|
|
75
|
+
expect(h.coordinator.suspendToolsForRepair()).toBeTrue();
|
|
76
|
+
h.coordinator.clear();
|
|
77
|
+
expect(h.activeTools()).toEqual(["read", "bash"]);
|
|
78
|
+
expect(h.coordinator.suspendToolsForRepair()).toBeFalse();
|
|
79
|
+
});
|
|
80
|
+
|
|
58
81
|
test("never authorizes extension-originated commands", () => {
|
|
59
82
|
const h = harness();
|
|
60
83
|
h.coordinator.hideTools();
|
|
@@ -553,6 +553,7 @@ fi
|
|
|
553
553
|
expect(extension).toContain("Publishing never starts or reruns a review");
|
|
554
554
|
expect(extension).toContain("review was cancelled");
|
|
555
555
|
expect(readme).toContain("handles that request directly");
|
|
556
|
+
expect(readme).toContain("automatically asks the same agent to correct its completed output once");
|
|
556
557
|
expect(readme).toContain("`allowStalePublish: true`");
|
|
557
558
|
expect(readme).toContain("/pr-review-publish 123 --allow-stale");
|
|
558
559
|
expect(readme).toContain("Inline comments are always disabled for stale reviews");
|