pi-pr-review 1.10.2 → 1.10.4

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.10.2"
2
+ ".": "1.10.4"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.4](https://github.com/10ego/pi-pr-review/compare/v1.10.3...v1.10.4) (2026-07-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **publish:** summarize findings without diff patches ([#29](https://github.com/10ego/pi-pr-review/issues/29)) ([bdee1ff](https://github.com/10ego/pi-pr-review/commit/bdee1ff024863019a2f357b04cffe944006a5726))
9
+
10
+ ## [1.10.3](https://github.com/10ego/pi-pr-review/compare/v1.10.2...v1.10.3) (2026-07-15)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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))
16
+
3
17
  ## [1.10.2](https://github.com/10ego/pi-pr-review/compare/v1.10.1...v1.10.2) (2026-07-15)
4
18
 
5
19
 
package/README.md CHANGED
@@ -184,7 +184,9 @@ 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
- 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.
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
+
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 GitHub omits patch metadata needed to validate an inline anchor, the affected finding remains in the review summary instead of blocking publication. 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.
190
192
 
@@ -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
- // Every other terminal response consumes authority, whether valid, empty, or unrelated.
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
- const publishable = parsePublishableReview(text);
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;
@@ -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) return undefined;
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
  }
@@ -837,6 +837,7 @@ function isInlineSeverity(finding: ReviewFindingLike): boolean {
837
837
  export interface CommentValidationResult {
838
838
  comments: PublishComment[];
839
839
  errors: string[];
840
+ warnings?: string[];
840
841
  }
841
842
 
842
843
  export function validateInlineComments(
@@ -844,6 +845,7 @@ export function validateInlineComments(
844
845
  changedFiles: ChangedFileLike[],
845
846
  ): CommentValidationResult {
846
847
  const errors: string[] = [];
848
+ const warnings: string[] = [];
847
849
  const comments: PublishComment[] = [];
848
850
  const files = new Map<string, ChangedFileLike>();
849
851
  for (const file of changedFiles) {
@@ -876,7 +878,9 @@ export function validateInlineComments(
876
878
  continue;
877
879
  }
878
880
  if (!file.patch) {
879
- errors.push(`${label}: diff patch is unavailable for anchor validation`);
881
+ // GitHub legitimately omits patch metadata for some large, binary, or transiently unavailable diffs.
882
+ // Keep the complete finding in the review summary rather than sending an unvalidated inline anchor.
883
+ warnings.push(`${label}: diff patch is unavailable; kept in the review summary`);
880
884
  continue;
881
885
  }
882
886
  const sideKey = side === "LEFT" ? "left" : "right";
@@ -913,7 +917,7 @@ export function validateInlineComments(
913
917
  if (comments.length > MAX_INLINE_COMMENTS) {
914
918
  errors.push(`too many inline comments (${comments.length}; max ${MAX_INLINE_COMMENTS})`);
915
919
  }
916
- return { comments, errors };
920
+ return { comments, errors, warnings };
917
921
  }
918
922
 
919
923
  export function collectFoldedComments(review: ReviewLike): CommentValidationResult {
@@ -954,7 +958,7 @@ export function collectFoldedComments(review: ReviewLike): CommentValidationResu
954
958
  ...(Number(start) < Number(end) ? { start_line: Number(start), start_side: side } : {}),
955
959
  });
956
960
  }
957
- return { comments, errors };
961
+ return { comments, errors, warnings: [] };
958
962
  }
959
963
 
960
964
  export function foldInlineComments(summary: string, comments: PublishComment[]): string {
@@ -1244,6 +1248,7 @@ export async function publishPullReview(input: {
1244
1248
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
1245
1249
  const isOpen = lifecycle.lifecycle === "open";
1246
1250
  let comments: PublishComment[] = [];
1251
+ let inlineWarningCount = 0;
1247
1252
  if (!isOpen) {
1248
1253
  const candidates = collectFoldedComments(review);
1249
1254
  if (candidates.errors.length > 0) {
@@ -1270,6 +1275,7 @@ export async function publishPullReview(input: {
1270
1275
  return { status: "failed", message: `inline validation failed: ${validated.errors.join("; ")}` };
1271
1276
  }
1272
1277
  comments = validated.comments;
1278
+ inlineWarningCount = validated.warnings?.length ?? 0;
1273
1279
  }
1274
1280
  }
1275
1281
 
@@ -1316,6 +1322,10 @@ export async function publishPullReview(input: {
1316
1322
  return { status: "failed", message: `final head check failed: ${String(error)}` };
1317
1323
  }
1318
1324
 
1325
+ const inlineWarning = inlineWarningCount > 0
1326
+ ? `; ${inlineWarningCount} inline finding${inlineWarningCount === 1 ? "" : "s"} kept in the summary because GitHub omitted diff patch metadata`
1327
+ : "";
1328
+ const degraded = !isOpen || headPlan.stale || inlineWarningCount > 0;
1319
1329
  const post = await runGh(
1320
1330
  githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
1321
1331
  cwd,
@@ -1328,13 +1338,12 @@ export async function publishPullReview(input: {
1328
1338
  } catch {
1329
1339
  /* accepted response without parseable metadata */
1330
1340
  }
1331
- const degraded = !isOpen || headPlan.stale;
1332
1341
  return {
1333
1342
  status: degraded ? "posted_degraded" : "posted",
1334
1343
  message: headPlan.stale
1335
1344
  ? `body-only stale COMMENT review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1336
1345
  : isOpen
1337
- ? "GitHub COMMENT review posted"
1346
+ ? `GitHub COMMENT review posted${inlineWarning}`
1338
1347
  : "body-only COMMENT review posted for non-open PR",
1339
1348
  reviewId: response.id,
1340
1349
  url: response.html_url,
@@ -1344,8 +1353,8 @@ export async function publishPullReview(input: {
1344
1353
  try {
1345
1354
  if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
1346
1355
  return {
1347
- status: !isOpen || headPlan.stale ? "posted_degraded" : "posted",
1348
- message: "GitHub review found during failure reconciliation",
1356
+ status: degraded ? "posted_degraded" : "posted",
1357
+ message: `GitHub review found during failure reconciliation${inlineWarning}`,
1349
1358
  reconciled: true,
1350
1359
  };
1351
1360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.10.2",
3
+ "version": "1.10.4",
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",
@@ -194,7 +194,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
194
194
  - `--comment` → force publication for this run, but never bypass validation, stale-head checks, or duplicate checks
195
195
  - `--no-comment` → suppress publication for this run
196
196
 
197
- When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths, suggested verdict, counts, nits, and findings that cannot be attached inline. Eligible P0–P3 diff-anchored findings are attached as inline comments within that same review and are omitted from the top-level issue list to avoid duplication. If multiple findings share one diff anchor, the first is attached inline and later findings remain in the top-level body instead of failing publication or dropping content. The API event is hardcoded to `COMMENT`: publication never sends `APPROVE` or `REQUEST_CHANGES`, even when the suggested verdict is `request_changes`. It appends the same-head marker, verifies the current head, validates every inline anchor against GitHub diff metadata, and refuses partial open-PR publication. For a known closed/merged PR it requires either trusted `--include-closed`/`--review-closed` invocation authority or the one-shot affirmative confirmation flow, then posts one body-only `COMMENT` review with each inline finding folded into the body exactly once. Unknown lifecycle states and unconfirmed non-open writes fail without posting or falling back to an issue comment.
197
+ When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths, suggested verdict, counts, nits, and findings that cannot be attached inline. Eligible P0–P3 diff-anchored findings are attached as inline comments within that same review and are omitted from the top-level issue list to avoid duplication. If GitHub omits patch metadata needed to validate an anchor, that finding remains in the top-level body instead of blocking publication. If multiple findings share one diff anchor, the first is attached inline and later findings remain in the top-level body instead of failing publication or dropping content. The API event is hardcoded to `COMMENT`: publication never sends `APPROVE` or `REQUEST_CHANGES`, even when the suggested verdict is `request_changes`. It appends the same-head marker, verifies the current head, validates every attempted inline anchor against GitHub diff metadata, and refuses partial open-PR publication. For a known closed/merged PR it requires either trusted `--include-closed`/`--review-closed` invocation authority or the one-shot affirmative confirmation flow, then posts one body-only `COMMENT` review with each inline finding folded into the body exactly once. Unknown lifecycle states and unconfirmed non-open writes fail without posting or falling back to an issue comment.
198
198
 
199
199
  The extension caches the latest valid completed review per repository and PR before publication preflight in the current Pi session. The session-backed cache survives extension reloads and session resumes but is bound to the originating session instance and repository. On a later turn, the extension intercepts that direct input before an agent turn, publishes only extension-cached content, permits stale publication, and cannot rerun review agents. Stale publication is also enabled by default through the invocation-captured `allowStalePublish` setting. Every stale review is body-only, displays a warning with the reviewed and preflight-observed commit hashes, and omits potentially invalid inline anchors. If the captured setting disabled stale publication, the user can still run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
200
200
 
@@ -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;
@@ -117,11 +118,12 @@ fi
117
118
  return dir;
118
119
  }
119
120
 
120
- function installFakePublishingGh(currentHead = "a".repeat(40)): string {
121
+ function installFakePublishingGh(currentHead = "a".repeat(40), patchless = false): string {
121
122
  const dir = mkdtempSync(join(tmpdir(), "pi-pr-review-publish-tool-"));
122
123
  tempDirs.push(dir);
123
124
  const gh = join(dir, "gh");
124
125
  const payloadPath = join(dir, "payload.json");
126
+ const changedFiles = patchless ? '[[{"filename":"src/parser.ts","status":"modified"}]]' : "[[]]";
125
127
  writeFileSync(
126
128
  gh,
127
129
  `#!/usr/bin/env bash
@@ -136,6 +138,8 @@ elif [[ "$args" == *"pulls/7/reviews?per_page=100"* || "$args" == *"issues/7/com
136
138
  elif [[ "$args" == *"--method POST"* ]]; then
137
139
  cat > '${payloadPath}'
138
140
  echo '{"id":42,"html_url":"https://github.com/owner/repo/pull/7#pullrequestreview-42"}'
141
+ elif [[ "$args" == *"pulls/7/files?per_page=100"* ]]; then
142
+ echo '${changedFiles}'
139
143
  elif [[ "$args" == *"repos/owner/repo/pulls/7"* ]]; then
140
144
  printf '{"state":"open","draft":false,"merged_at":null,"head":{"sha":"%s"}}\n' '${currentHead}'
141
145
  else
@@ -156,6 +160,7 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
156
160
  const tools = new Map<string, any>();
157
161
  const branch = [...initialBranch];
158
162
  const notifications: string[] = [];
163
+ const sentMessages: Array<{ message: any; options: any }> = [];
159
164
  let activeTools = ["read", "bash", ...REVIEW_LOOP_TOOL_NAMES];
160
165
  let aborts = 0;
161
166
  let promptPath = ownPromptPath;
@@ -191,6 +196,9 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
191
196
  appendEntry: (customType: string, data: unknown) => {
192
197
  branch.push({ type: "custom", id: `custom-${nextId++}`, customType, data });
193
198
  },
199
+ sendMessage: (message: any, options: any) => {
200
+ sentMessages.push({ message, options });
201
+ },
194
202
  getActiveTools: () => [...activeTools],
195
203
  setActiveTools: (next: string[]) => {
196
204
  activeTools = [...next];
@@ -210,6 +218,7 @@ function createHarness(initialBranch: any[] = [], identity = session): Harness {
210
218
  tools,
211
219
  branch,
212
220
  notifications,
221
+ sentMessages,
213
222
  activeTools: () => [...activeTools],
214
223
  abortCount: () => aborts,
215
224
  selfReviewCoordinator,
@@ -241,6 +250,132 @@ function persistedInlineReview(identity = session, allowStalePublish = true): an
241
250
  }
242
251
 
243
252
  describe("completed review extension lifecycle", () => {
253
+ test("automatically corrects invalid final JSON once and attempts publication", async () => {
254
+ const harness = createHarness();
255
+ await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
256
+ const wrapped = {
257
+ role: "assistant",
258
+ stopReason: "stop",
259
+ content: [{ type: "text", text: `\`\`\`json\n${JSON.stringify(review)}\n\`\`\`` }],
260
+ };
261
+ await harness.emit("message_end", { message: wrapped });
262
+ expect(harness.sentMessages).toHaveLength(1);
263
+ expect(harness.sentMessages[0]?.message).toMatchObject({
264
+ customType: "pr-review-output-repair",
265
+ display: false,
266
+ });
267
+ expect(harness.sentMessages[0]?.message.content).toContain("exactly one JSON object");
268
+ expect(harness.sentMessages[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
269
+ expect(harness.notifications.some((message) => message.includes("automatically correcting"))).toBeTrue();
270
+ expect(harness.activeTools()).toEqual([]);
271
+
272
+ harness.appendMessage(wrapped, "wrapped-review");
273
+ await harness.emit("turn_end", { message: wrapped, toolResults: [] });
274
+ expect(harness.notifications.some((message) => message.includes("was not posted"))).toBeFalse();
275
+
276
+ const payloadPath = installFakePublishingGh();
277
+ const corrected = {
278
+ role: "assistant",
279
+ stopReason: "stop",
280
+ content: [{ type: "text", text: JSON.stringify(review) }],
281
+ };
282
+ await harness.emit("message_end", { message: corrected });
283
+ harness.appendMessage(corrected, "corrected-review");
284
+ await harness.emit("turn_end", { message: corrected, toolResults: [] });
285
+
286
+ expect(harness.sentMessages).toHaveLength(1);
287
+ expect(harness.notifications.some((message) => message.includes("PR review posted"))).toBeTrue();
288
+ expect(JSON.parse(readFileSync(payloadPath, "utf8")).body).toContain("Checks lifecycle persistence");
289
+ expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
290
+ });
291
+
292
+ test("aborts and revokes repair authority when the correction attempts a tool call", async () => {
293
+ const harness = createHarness();
294
+ await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
295
+ const invalid = {
296
+ role: "assistant",
297
+ stopReason: "stop",
298
+ content: [{ type: "text", text: "not json" }],
299
+ };
300
+ await harness.emit("message_end", { message: invalid });
301
+ expect(harness.activeTools()).toEqual([]);
302
+
303
+ await harness.emit("message_end", {
304
+ message: {
305
+ role: "assistant",
306
+ stopReason: "toolUse",
307
+ content: [{ type: "toolCall", id: "repair-bash", name: "bash", arguments: { command: "echo unsafe" } }],
308
+ },
309
+ });
310
+
311
+ expect(harness.abortCount()).toBe(1);
312
+ expect(harness.sentMessages).toHaveLength(1);
313
+ expect(harness.notifications.some((message) => message.includes("correction attempted to call tools"))).toBeTrue();
314
+ expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
315
+ });
316
+
317
+ test("keeps tools suspended until a cancelled repair definitively settles", async () => {
318
+ const harness = createHarness();
319
+ await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
320
+ const invalid = {
321
+ role: "assistant",
322
+ stopReason: "stop",
323
+ content: [{ type: "text", text: "not json" }],
324
+ };
325
+ await harness.emit("message_end", { message: invalid });
326
+ expect(harness.activeTools()).toEqual([]);
327
+
328
+ const overlap = await harness.emit("input", {
329
+ text: "do something unrelated",
330
+ source: "interactive",
331
+ streamingBehavior: "steer",
332
+ });
333
+ expect(overlap).toContainEqual({ action: "handled" });
334
+ expect(harness.abortCount()).toBe(1);
335
+ expect(harness.activeTools()).toEqual([]);
336
+ expect(harness.notifications.some((message) => message.includes("was not queued"))).toBeTrue();
337
+
338
+ const staleCorrection = {
339
+ role: "assistant",
340
+ stopReason: "stop",
341
+ content: [{ type: "text", text: JSON.stringify(review) }],
342
+ };
343
+ await harness.emit("message_end", { message: staleCorrection });
344
+ harness.appendMessage(staleCorrection, "stale-correction");
345
+ await harness.emit("turn_end", { message: staleCorrection, toolResults: [] });
346
+ expect(harness.notifications.some((message) => message.includes("PR review posted"))).toBeFalse();
347
+ expect(harness.activeTools()).toEqual([]);
348
+
349
+ await harness.emit("agent_settled", {});
350
+ expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
351
+ });
352
+
353
+ test("stops after one automatic correction attempt", async () => {
354
+ const harness = createHarness();
355
+ await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
356
+ const invalid = {
357
+ role: "assistant",
358
+ stopReason: "stop",
359
+ content: [{ type: "text", text: "not json" }],
360
+ };
361
+ await harness.emit("message_end", { message: invalid });
362
+ expect(harness.sentMessages).toHaveLength(1);
363
+ expect(harness.activeTools()).toEqual([]);
364
+ await harness.emit("turn_end", { message: invalid, toolResults: [] });
365
+
366
+ await harness.emit("message_end", { message: invalid });
367
+ harness.appendMessage(invalid, "invalid-retry");
368
+ await harness.emit("turn_end", { message: invalid, toolResults: [] });
369
+
370
+ expect(harness.sentMessages).toHaveLength(1);
371
+ expect(
372
+ harness.notifications.some((message) =>
373
+ message.includes("PR review was not posted: final response is not exactly one JSON object"),
374
+ ),
375
+ ).toBeTrue();
376
+ expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
377
+ });
378
+
244
379
  test("persists a reference before publishing after Pi stores exact assistant JSON", async () => {
245
380
  const harness = createHarness();
246
381
  await harness.emit("input", { text: "/pr-review 7 --comment", source: "interactive" });
@@ -423,6 +558,41 @@ describe("completed review extension lifecycle", () => {
423
558
  expect(payload.body).toContain(currentHead);
424
559
  });
425
560
 
561
+ test("surfaces patchless inline fallback in the posted notification", async () => {
562
+ const patchlessReview: ReviewLike = {
563
+ ...review,
564
+ findings: [
565
+ {
566
+ title: "[P2] Patchless finding",
567
+ severity: "P2",
568
+ blocking: false,
569
+ body: "This finding must remain visible in the summary.",
570
+ confidence_score: 0.9,
571
+ code_location: {
572
+ absolute_file_path: "src/parser.ts",
573
+ line_range: { start: 2, end: 2 },
574
+ side: "RIGHT",
575
+ commentable: true,
576
+ },
577
+ },
578
+ ],
579
+ };
580
+ const cache = new CompletedReviewCache();
581
+ const record = cache.remember(patchlessReview, invocation, repository);
582
+ const persisted = cache.persist(record, session);
583
+ const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
584
+ const harness = createHarness([cacheEntry]);
585
+ await harness.emit("session_start", { reason: "reload" });
586
+ const payloadPath = installFakePublishingGh("a".repeat(40), true);
587
+
588
+ await harness.commands.get("pr-review-publish")!("7", harness.ctx);
589
+
590
+ expect(harness.notifications.some((message) => message.includes("1 inline finding kept in the summary"))).toBeTrue();
591
+ const payload = JSON.parse(readFileSync(payloadPath, "utf8"));
592
+ expect(payload.comments).toBeUndefined();
593
+ expect(payload.body).toContain("[P2] Patchless finding");
594
+ });
595
+
426
596
  test("rejects extension-generated, queued, and steering publish requests", async () => {
427
597
  const persisted = persistedInlineReview();
428
598
  const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
@@ -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();
@@ -536,6 +536,68 @@ fi
536
536
  }
537
537
  });
538
538
 
539
+ test("publishes a current review with patchless findings in the summary", async () => {
540
+ const dir = mkdtempSync(join(tmpdir(), "pi-pr-review-gh-"));
541
+ const gh = join(dir, "gh");
542
+ const payloadPath = join(dir, "payload.json");
543
+ writeFileSync(
544
+ gh,
545
+ `#!/usr/bin/env bash
546
+ set -euo pipefail
547
+ args="$*"
548
+ if [[ "$args" == "repo view --json nameWithOwner,url" ]]; then
549
+ echo '{"nameWithOwner":"owner/repo","url":"https://github.com/owner/repo"}'
550
+ elif [[ "$args" == *" user --jq .login"* ]]; then
551
+ echo 'reviewer'
552
+ elif [[ "$args" == *"--method POST"* ]]; then
553
+ cat > "$GH_FAKE_PAYLOAD"
554
+ echo '{"id":43,"html_url":"https://github.com/owner/repo/pull/7#pullrequestreview-43"}'
555
+ elif [[ "$args" == *"pulls/7/reviews?per_page=100"* || "$args" == *"issues/7/comments?per_page=100"* ]]; then
556
+ echo '[]'
557
+ elif [[ "$args" == *"pulls/7/files?per_page=100"* ]]; then
558
+ echo '[[{"filename":"src/parser.ts","status":"modified"}]]'
559
+ elif [[ "$args" == *"repos/owner/repo/pulls/7"* ]]; then
560
+ printf '{"state":"open","draft":false,"merged_at":null,"head":{"sha":"%s"}}\n' "$GH_FAKE_CURRENT"
561
+ else
562
+ echo "unexpected gh args: $args" >&2
563
+ exit 1
564
+ fi
565
+ `,
566
+ );
567
+ chmodSync(gh, 0o755);
568
+ const previousPath = process.env.PATH;
569
+ const previousPayload = process.env.GH_FAKE_PAYLOAD;
570
+ const previousCurrent = process.env.GH_FAKE_CURRENT;
571
+ const current = "a".repeat(40);
572
+ process.env.PATH = `${dir}:${previousPath ?? ""}`;
573
+ process.env.GH_FAKE_PAYLOAD = payloadPath;
574
+ process.env.GH_FAKE_CURRENT = current;
575
+ try {
576
+ const posted = await publishPullReview({
577
+ cwd: dir,
578
+ prNumber: 7,
579
+ headSha: current,
580
+ allowNonOpen: false,
581
+ expectedRepository: { hostname: "github.com", repository: "owner/repo" },
582
+ review,
583
+ });
584
+ expect(posted.status).toBe("posted_degraded");
585
+ expect(posted.message).toContain("1 inline finding kept in the summary");
586
+ const payload = JSON.parse(readFileSync(payloadPath, "utf8"));
587
+ expect(payload.comments).toBeUndefined();
588
+ expect(payload.body).toContain("[P2] Handle empty input");
589
+ expect(payload.body).toContain("Empty input currently returns the wrong value.");
590
+ } finally {
591
+ if (previousPath === undefined) delete process.env.PATH;
592
+ else process.env.PATH = previousPath;
593
+ if (previousPayload === undefined) delete process.env.GH_FAKE_PAYLOAD;
594
+ else process.env.GH_FAKE_PAYLOAD = previousPayload;
595
+ if (previousCurrent === undefined) delete process.env.GH_FAKE_CURRENT;
596
+ else process.env.GH_FAKE_CURRENT = previousCurrent;
597
+ rmSync(dir, { recursive: true, force: true });
598
+ }
599
+ });
600
+
539
601
  test("preserves inline publication when the reviewed head is still current", () => {
540
602
  const head = "a".repeat(40);
541
603
  expect(planHeadPublication(head, head.toUpperCase(), false).plan).toMatchObject({
@@ -553,6 +615,7 @@ fi
553
615
  expect(extension).toContain("Publishing never starts or reruns a review");
554
616
  expect(extension).toContain("review was cancelled");
555
617
  expect(readme).toContain("handles that request directly");
618
+ expect(readme).toContain("automatically asks the same agent to correct its completed output once");
556
619
  expect(readme).toContain("`allowStalePublish: true`");
557
620
  expect(readme).toContain("/pr-review-publish 123 --allow-stale");
558
621
  expect(readme).toContain("Inline comments are always disabled for stale reviews");
@@ -684,6 +747,18 @@ describe("atomic COMMENT review payload", () => {
684
747
  expect(result.errors[0]).toContain("not inside one diff hunk");
685
748
  });
686
749
 
750
+ test("keeps findings in the summary when GitHub omits patch metadata", () => {
751
+ const result = validateInlineComments(review, [{ filename: "src/parser.ts" }]);
752
+ expect(result.errors).toEqual([]);
753
+ expect(result.comments).toEqual([]);
754
+ expect(result.warnings).toEqual([
755
+ "finding 1: diff patch is unavailable; kept in the review summary",
756
+ ]);
757
+ const summary = buildReviewSummary(review, result.comments);
758
+ expect(summary).toContain("2 total (0 inline, 2 summary-only)");
759
+ expect(summary).toContain("[P2] Handle empty input");
760
+ });
761
+
687
762
  test("folds inline findings exactly once into a body-only non-open review", () => {
688
763
  const folded = collectFoldedComments(review);
689
764
  expect(folded.errors).toEqual([]);