pi-pr-review 1.6.5 → 1.6.6

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.6.5"
2
+ ".": "1.6.6"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.6](https://github.com/10ego/pi-pr-review/compare/v1.6.5...v1.6.6) (2026-07-13)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **publish:** persist completed review cache ([#10](https://github.com/10ego/pi-pr-review/issues/10)) ([e5a1f33](https://github.com/10ego/pi-pr-review/commit/e5a1f333ce6a4bbf624d889a0954bdd251e7d902))
9
+
3
10
  ## [1.6.5](https://github.com/10ego/pi-pr-review/compare/v1.6.4...v1.6.5) (2026-07-13)
4
11
 
5
12
 
package/README.md CHANGED
@@ -150,7 +150,7 @@ If a new commit makes a completed review stale, publish the cached result withou
150
150
  /pr-review-publish 123 --allow-stale
151
151
  ```
152
152
 
153
- Inline comments are intentionally disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and identifies both the reviewed and current SHAs. The cache lasts for the current Pi session.
153
+ Inline comments are intentionally disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and identifies both the reviewed and current SHAs. The cache is stored in the current Pi session, survives extension reloads and session resumes, and is bound to that session instance's ID and creation metadata as well as the repository.
154
154
 
155
155
  ## Optional verification
156
156
 
@@ -19,6 +19,8 @@ import {
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import {
21
21
  classifyAssistantCompletion,
22
+ COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE,
23
+ COMPLETED_REVIEW_ENTRY_TYPE,
22
24
  CompletedReviewCache,
23
25
  decideReviewPublication,
24
26
  isNonOpenConfirmationPrompt,
@@ -28,10 +30,13 @@ import {
28
30
  publishPullReview,
29
31
  resolveAutoPostSetting,
30
32
  resolveRepositoryBinding,
33
+ restoreCompletedReviewBranch,
31
34
  ReviewInvocationGate,
32
35
  shouldPublishReview,
33
36
  validateReviewInvocation,
34
37
  type AutoPostResolution,
38
+ type CompletedReviewRecord,
39
+ type CompletedReviewSessionIdentity,
35
40
  type RepositoryBinding,
36
41
  type ReviewInvocation,
37
42
  type ReviewLike,
@@ -363,6 +368,13 @@ async function maybePublishReview(
363
368
  ctx.ui.notify("PR review was not posted: final JSON is missing pr.head_sha", "error");
364
369
  return;
365
370
  }
371
+ if (!expectedRepository) {
372
+ ctx.ui.notify(
373
+ "PR review was not posted because its repository identity could not be established before caching; no publish-only cache is available. Rerun /pr-review when GitHub repository lookup is working.",
374
+ "error",
375
+ );
376
+ return;
377
+ }
366
378
  const result = await publishPullReview({
367
379
  cwd: ctx.cwd,
368
380
  prNumber: invocation.prNumber,
@@ -377,6 +389,30 @@ async function maybePublishReview(
377
389
  export default function (pi: ExtensionAPI) {
378
390
  const invocationGate = new ReviewInvocationGate();
379
391
  const completedReviews = new CompletedReviewCache();
392
+ const sessionIdentity = (ctx: ExtensionContext): CompletedReviewSessionIdentity | undefined => {
393
+ const header = ctx.sessionManager.getHeader();
394
+ const id = ctx.sessionManager.getSessionId();
395
+ return header?.id === id && typeof header.timestamp === "string"
396
+ ? { id, startedAt: header.timestamp }
397
+ : undefined;
398
+ };
399
+ const restoreCompletedReviews = (ctx: ExtensionContext) => {
400
+ const session = sessionIdentity(ctx);
401
+ if (!session) {
402
+ completedReviews.clear();
403
+ return;
404
+ }
405
+ restoreCompletedReviewBranch(completedReviews, ctx.sessionManager.getBranch(), session);
406
+ };
407
+ let pendingCompletion:
408
+ | {
409
+ text: string;
410
+ invocation: ReviewInvocation;
411
+ repository?: RepositoryBinding;
412
+ record?: CompletedReviewRecord;
413
+ session?: CompletedReviewSessionIdentity;
414
+ }
415
+ | undefined;
380
416
  const telemetryTracker = new ReviewTelemetryTracker();
381
417
  const persistTelemetry = (completion: ReviewPerformanceTelemetry["completion"]) => {
382
418
  const telemetry = telemetryTracker.finish(completion);
@@ -444,12 +480,28 @@ export default function (pi: ExtensionAPI) {
444
480
  },
445
481
  });
446
482
 
447
- pi.on("session_start", () => {
483
+ pi.on("session_start", (_event, ctx) => {
448
484
  invocationGate.clear();
449
- completedReviews.clear();
485
+ pendingCompletion = undefined;
486
+ restoreCompletedReviews(ctx);
450
487
  telemetryTracker.clear();
451
488
  });
452
489
 
490
+ pi.on("session_tree", (event, ctx) => {
491
+ invocationGate.clear();
492
+ pendingCompletion = undefined;
493
+ restoreCompletedReviews(ctx);
494
+ telemetryTracker.clear();
495
+ const session = sessionIdentity(ctx);
496
+ if (event.summaryEntry || !session) return;
497
+ try {
498
+ // Pi otherwise resumes at the JSONL tail, not a no-summary /tree selection.
499
+ pi.appendEntry(COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE, { schemaVersion: 2, session });
500
+ } catch (error) {
501
+ ctx.ui.notify(`PR review cache branch selection will not survive session resume: ${String(error)}`, "warning");
502
+ }
503
+ });
504
+
453
505
  pi.on("input", (event, ctx) => {
454
506
  if (invocationGate.phase() === "awaiting_confirmation") {
455
507
  const confirmation = invocationGate.resolveConfirmationInput(event.text);
@@ -486,6 +538,41 @@ export default function (pi: ExtensionAPI) {
486
538
  telemetryTracker.toolEnded(event.toolCallId);
487
539
  });
488
540
 
541
+ pi.on("turn_end", async (_event, ctx) => {
542
+ const pending = pendingCompletion;
543
+ pendingCompletion = undefined;
544
+ if (!pending) return;
545
+ if (pending.record && pending.session) {
546
+ const currentSession = sessionIdentity(ctx);
547
+ if (
548
+ !currentSession ||
549
+ currentSession.id !== pending.session.id ||
550
+ currentSession.startedAt !== pending.session.startedAt
551
+ ) {
552
+ ctx.ui.notify("Completed review was not persisted or posted because the session identity changed", "warning");
553
+ return;
554
+ }
555
+ const leaf = ctx.sessionManager.getLeafEntry();
556
+ const leafText = leaf?.type === "message" && leaf.message.role === "assistant"
557
+ ? assistantText(leaf.message as { content?: MessagePart[] })
558
+ : "";
559
+ const leafReview = parsePublishableReview(leafText).review;
560
+ const reviewEntryId = leaf?.type === "message" && leaf.message.role === "assistant" && leafReview
561
+ ? leaf.id
562
+ : undefined;
563
+ try {
564
+ // Persist before any GitHub preflight so a failed post always remains retryable.
565
+ pi.appendEntry(
566
+ COMPLETED_REVIEW_ENTRY_TYPE,
567
+ completedReviews.persist(pending.record, pending.session, reviewEntryId, leafReview),
568
+ );
569
+ } catch (error) {
570
+ ctx.ui.notify(`Completed review cache will not survive an extension reload: ${String(error)}`, "warning");
571
+ }
572
+ }
573
+ await maybePublishReview(pending.text, pending.invocation, ctx, pending.repository);
574
+ });
575
+
489
576
  pi.on("message_end", async (event, ctx) => {
490
577
  if (event.message.role !== "assistant") return;
491
578
  const completion = classifyAssistantCompletion(event.message.stopReason, hasToolCall(event.message));
@@ -517,6 +604,8 @@ export default function (pi: ExtensionAPI) {
517
604
  if (invocation) {
518
605
  const publishable = parsePublishableReview(text);
519
606
  let repository: RepositoryBinding | undefined;
607
+ let record: CompletedReviewRecord | undefined;
608
+ let session: CompletedReviewSessionIdentity | undefined;
520
609
  if (
521
610
  publishable.review &&
522
611
  !validateReviewInvocation(publishable.review, invocation) &&
@@ -524,13 +613,17 @@ export default function (pi: ExtensionAPI) {
524
613
  ) {
525
614
  try {
526
615
  repository = await resolveRepositoryBinding(ctx.cwd);
527
- // Cache before publication preflight so a stale failure remains directly publishable.
528
- completedReviews.remember(publishable.review, invocation, repository);
616
+ // Cache before publication preflight; persist after Pi stores the assistant message.
617
+ record = completedReviews.remember(publishable.review, invocation, repository);
618
+ session = sessionIdentity(ctx);
619
+ if (!session) {
620
+ ctx.ui.notify("Completed review cache will not survive reload: session identity is unavailable", "warning");
621
+ }
529
622
  } catch (error) {
530
623
  ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
531
624
  }
532
625
  }
533
- await maybePublishReview(text, invocation, ctx, repository);
626
+ pendingCompletion = { text, invocation, repository, record, session };
534
627
  }
535
628
 
536
629
  // Keep raw JSON for automation; only prettify for interactive terminals.
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
 
4
5
  export type PublishMode = "auto" | "force" | "disabled";
5
6
  export type AutoPostSource = "default" | "user" | "project";
@@ -313,20 +314,164 @@ export interface CompletedReviewRecord {
313
314
  repository: RepositoryBinding;
314
315
  }
315
316
 
317
+ export const COMPLETED_REVIEW_ENTRY_TYPE = "pr-review-completed";
318
+ export const COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE = "pr-review-cache-branch";
319
+
320
+ export interface CompletedReviewSessionIdentity {
321
+ id: string;
322
+ startedAt: string;
323
+ }
324
+
325
+ export interface PersistedCompletedReview {
326
+ schemaVersion: 2;
327
+ session: CompletedReviewSessionIdentity;
328
+ invocation: ReviewInvocation;
329
+ repository: RepositoryBinding;
330
+ reviewHash: string;
331
+ reviewEntryId?: string;
332
+ review?: ReviewLike;
333
+ }
334
+
335
+ export interface CompletedReviewSessionEntryLike {
336
+ type: string;
337
+ id?: string;
338
+ customType?: string;
339
+ data?: unknown;
340
+ message?: unknown;
341
+ }
342
+
316
343
  function completedReviewKey(repository: RepositoryBinding, prNumber: number): string {
317
344
  return `${repository.hostname.toLowerCase()}:${repository.repository.toLowerCase()}:${prNumber}`;
318
345
  }
319
346
 
347
+ export function validRepositoryBinding(value: unknown): value is RepositoryBinding {
348
+ if (!isObject(value)) return false;
349
+ return (
350
+ typeof value.repository === "string" &&
351
+ /^[^/\s]+\/[^/\s]+$/.test(value.repository) &&
352
+ typeof value.hostname === "string" &&
353
+ /^[a-z0-9.-]+$/i.test(value.hostname)
354
+ );
355
+ }
356
+
357
+ function validSessionIdentity(value: unknown): value is CompletedReviewSessionIdentity {
358
+ return (
359
+ isObject(value) &&
360
+ typeof value.id === "string" &&
361
+ value.id.length > 0 &&
362
+ typeof value.startedAt === "string" &&
363
+ value.startedAt.length > 0
364
+ );
365
+ }
366
+
367
+ function sameSessionIdentity(left: CompletedReviewSessionIdentity, right: CompletedReviewSessionIdentity): boolean {
368
+ return left.id === right.id && left.startedAt === right.startedAt;
369
+ }
370
+
371
+ function reviewHash(review: ReviewLike): string {
372
+ return createHash("sha256").update(JSON.stringify(review)).digest("hex");
373
+ }
374
+
375
+ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined {
376
+ if (!isObject(value)) return undefined;
377
+ if (!new Set(["auto", "force", "disabled"]).has(String(value.mode))) return undefined;
378
+ if (!Number.isInteger(value.prNumber) || Number(value.prNumber) <= 0 || typeof value.allowNonOpen !== "boolean") {
379
+ return undefined;
380
+ }
381
+ const autoPost = value.autoPost;
382
+ if (
383
+ !isObject(autoPost) ||
384
+ typeof autoPost.value !== "boolean" ||
385
+ typeof autoPost.valid !== "boolean" ||
386
+ !new Set(["default", "user", "project"]).has(String(autoPost.source)) ||
387
+ (autoPost.error !== undefined && typeof autoPost.error !== "string")
388
+ ) {
389
+ return undefined;
390
+ }
391
+ return {
392
+ mode: value.mode as PublishMode,
393
+ prNumber: Number(value.prNumber),
394
+ allowNonOpen: value.allowNonOpen,
395
+ autoPost: {
396
+ value: autoPost.value,
397
+ valid: autoPost.valid,
398
+ source: autoPost.source as AutoPostSource,
399
+ ...(typeof autoPost.error === "string" ? { error: autoPost.error } : {}),
400
+ },
401
+ };
402
+ }
403
+
320
404
  /** Session-scoped latest completed review per repository and PR. */
321
405
  export class CompletedReviewCache {
322
406
  private readonly reviews = new Map<string, CompletedReviewRecord>();
323
407
 
324
- remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): void {
325
- this.reviews.set(completedReviewKey(repository, invocation.prNumber), {
408
+ remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): CompletedReviewRecord {
409
+ const record = {
326
410
  review,
327
- invocation: { ...invocation },
411
+ invocation: { ...invocation, autoPost: { ...invocation.autoPost } },
328
412
  repository: { ...repository },
329
- });
413
+ };
414
+ this.reviews.set(completedReviewKey(repository, invocation.prNumber), record);
415
+ return record;
416
+ }
417
+
418
+ persist(
419
+ record: CompletedReviewRecord,
420
+ session: CompletedReviewSessionIdentity,
421
+ reviewEntryId?: string,
422
+ referencedReview?: ReviewLike,
423
+ ): PersistedCompletedReview {
424
+ const digest = reviewHash(record.review);
425
+ const useReference = !!reviewEntryId && !!referencedReview && reviewHash(referencedReview) === digest;
426
+ return {
427
+ schemaVersion: 2,
428
+ session: { ...session },
429
+ invocation: { ...record.invocation, autoPost: { ...record.invocation.autoPost } },
430
+ repository: { ...record.repository },
431
+ reviewHash: digest,
432
+ ...(useReference ? { reviewEntryId } : { review: record.review }),
433
+ };
434
+ }
435
+
436
+ /** Restore only strictly validated state created by this exact Pi session instance. */
437
+ restore(
438
+ value: unknown,
439
+ session: CompletedReviewSessionIdentity,
440
+ referencedReview?: ReviewLike,
441
+ ): boolean {
442
+ if (
443
+ !isObject(value) ||
444
+ value.schemaVersion !== 2 ||
445
+ !validSessionIdentity(value.session) ||
446
+ !sameSessionIdentity(value.session, session) ||
447
+ !validRepositoryBinding(value.repository)
448
+ ) {
449
+ return false;
450
+ }
451
+ const invocation = parsePersistedInvocation(value.invocation);
452
+ if (!invocation || typeof value.reviewHash !== "string" || !/^[0-9a-f]{64}$/.test(value.reviewHash)) {
453
+ return false;
454
+ }
455
+ const hasReference = typeof value.reviewEntryId === "string" && value.reviewEntryId.length > 0;
456
+ const hasInlineReview = Object.prototype.hasOwnProperty.call(value, "review");
457
+ if (hasReference === hasInlineReview) return false;
458
+ const candidate = hasReference ? referencedReview : value.review;
459
+ let parsed: PublishableReviewParseResult;
460
+ try {
461
+ parsed = parsePublishableReview(JSON.stringify(candidate));
462
+ } catch {
463
+ return false;
464
+ }
465
+ if (
466
+ !parsed.review ||
467
+ reviewHash(parsed.review) !== value.reviewHash ||
468
+ !shouldPublishReview(parsed.review) ||
469
+ validateReviewInvocation(parsed.review, invocation)
470
+ ) {
471
+ return false;
472
+ }
473
+ this.remember(parsed.review, invocation, value.repository);
474
+ return true;
330
475
  }
331
476
 
332
477
  get(prNumber: number, repository: RepositoryBinding): CompletedReviewRecord | undefined {
@@ -338,6 +483,45 @@ export class CompletedReviewCache {
338
483
  }
339
484
  }
340
485
 
486
+ function reviewFromSessionMessage(entry: CompletedReviewSessionEntryLike | undefined): ReviewLike | undefined {
487
+ if (!entry || entry.type !== "message" || !isObject(entry.message) || entry.message.role !== "assistant") {
488
+ return undefined;
489
+ }
490
+ const content = entry.message.content;
491
+ const text = typeof content === "string"
492
+ ? content
493
+ : Array.isArray(content)
494
+ ? content
495
+ .filter((part) => isObject(part) && part.type === "text" && typeof part.text === "string")
496
+ .map((part) => String(part.text))
497
+ .join("")
498
+ : "";
499
+ return parsePublishableReview(text).review;
500
+ }
501
+
502
+ /** Rebuild cache state after session load, reload, resume, or tree navigation. */
503
+ export function restoreCompletedReviewBranch(
504
+ cache: CompletedReviewCache,
505
+ entries: CompletedReviewSessionEntryLike[],
506
+ session: CompletedReviewSessionIdentity,
507
+ ): number {
508
+ cache.clear();
509
+ const seenEntries = new Map<string, CompletedReviewSessionEntryLike>();
510
+ let restored = 0;
511
+ for (const entry of entries) {
512
+ if (typeof entry.id === "string") seenEntries.set(entry.id, entry);
513
+ if (entry.type !== "custom" || entry.customType !== COMPLETED_REVIEW_ENTRY_TYPE) continue;
514
+ const reviewEntryId = isObject(entry.data) && typeof entry.data.reviewEntryId === "string"
515
+ ? entry.data.reviewEntryId
516
+ : undefined;
517
+ const referencedReview = reviewEntryId
518
+ ? reviewFromSessionMessage(seenEntries.get(reviewEntryId))
519
+ : undefined;
520
+ if (cache.restore(entry.data, session, referencedReview)) restored++;
521
+ }
522
+ return restored;
523
+ }
524
+
341
525
  export interface PublishableReviewParseResult {
342
526
  review?: ReviewLike;
343
527
  error?: string;
@@ -764,10 +948,9 @@ export async function resolveRepositoryBinding(cwd: string): Promise<RepositoryB
764
948
  );
765
949
  const repository = String(repoInfo.nameWithOwner ?? "");
766
950
  const hostname = new URL(String(repoInfo.url ?? "")).hostname;
767
- if (!/^[^/\s]+\/[^/\s]+$/.test(repository) || !/^[a-z0-9.-]+$/i.test(hostname)) {
768
- throw new Error("invalid GitHub repository or hostname");
769
- }
770
- return { repository, hostname };
951
+ const binding = { repository, hostname };
952
+ if (!validRepositoryBinding(binding)) throw new Error("invalid GitHub repository or hostname");
953
+ return binding;
771
954
  }
772
955
 
773
956
  function flattenPages<T>(value: unknown): T[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
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",
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
196
196
 
197
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. 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.
198
198
 
199
- The extension caches the latest valid completed review per repository and PR before publication preflight for the current extension session. If publication reports that the PR head changed, the user can run `/pr-review-publish <PR-NUM> --allow-stale` to post that cached result without another model turn. This explicit stale override produces a body-only `COMMENT` review with the reviewed and preflight-observed SHAs disclosed and no potentially invalid inline anchors. Never rerun the review merely to change posting intent, and never attempt the GitHub write yourself.
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. If publication reports that the PR head changed, the user can run `/pr-review-publish <PR-NUM> --allow-stale` to post that cached result without another model turn. This explicit stale override produces a body-only `COMMENT` review with the reviewed and preflight-observed SHAs disclosed and no potentially invalid inline anchors. Never rerun the review merely to change posting intent, and never attempt the GitHub write yourself.
200
200
 
201
201
  ---
202
202
 
@@ -0,0 +1,197 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE,
7
+ COMPLETED_REVIEW_ENTRY_TYPE,
8
+ CompletedReviewCache,
9
+ resolveAutoPostSetting,
10
+ type CompletedReviewSessionIdentity,
11
+ type ReviewLike,
12
+ } from "../lib/pr-review-publish.ts";
13
+
14
+ mock.module("@earendil-works/pi-coding-agent", () => ({
15
+ CONFIG_DIR_NAME: ".pi",
16
+ getAgentDir: () => join(tmpdir(), "pi-pr-review-empty-agent-dir"),
17
+ }));
18
+
19
+ const reviewTable = (await import("../extensions/review-table.ts")).default;
20
+
21
+ const review: ReviewLike = {
22
+ pr: { number: 7, title: "Lifecycle review", head_sha: "a".repeat(40) },
23
+ disposition: "reviewed",
24
+ verification: "Not run.",
25
+ overview: "Checks lifecycle persistence.",
26
+ strengths: [],
27
+ findings: [],
28
+ notes: { correctness: "", security: "", performance: "" },
29
+ verdict: "approve",
30
+ overall_correctness: "patch is correct",
31
+ overall_explanation: "No issues found.",
32
+ overall_confidence_score: 0.9,
33
+ };
34
+
35
+ const session: CompletedReviewSessionIdentity = {
36
+ id: "shared-explicit-id",
37
+ startedAt: "2026-07-13T00:00:00.000Z",
38
+ };
39
+ const repository = { hostname: "github.com", repository: "owner/repo" };
40
+ const invocation = {
41
+ mode: "disabled" as const,
42
+ prNumber: 7,
43
+ allowNonOpen: false,
44
+ autoPost: resolveAutoPostSetting({ autoPostReviews: false }),
45
+ };
46
+
47
+ interface Harness {
48
+ handlers: Map<string, Array<(event: any, ctx: any) => any>>;
49
+ commands: Map<string, (args: string, ctx: any) => Promise<void>>;
50
+ branch: any[];
51
+ notifications: string[];
52
+ ctx: any;
53
+ appendMessage(message: any, id?: string): any;
54
+ emit(name: string, event: any): Promise<any[]>;
55
+ }
56
+
57
+ const tempDirs: string[] = [];
58
+ let previousPath: string | undefined;
59
+
60
+ afterEach(() => {
61
+ if (previousPath !== undefined) process.env.PATH = previousPath;
62
+ previousPath = undefined;
63
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
64
+ });
65
+
66
+ function installFakeGh(): string {
67
+ const dir = mkdtempSync(join(tmpdir(), "pi-pr-review-lifecycle-"));
68
+ tempDirs.push(dir);
69
+ const gh = join(dir, "gh");
70
+ writeFileSync(
71
+ gh,
72
+ `#!/usr/bin/env bash
73
+ set -euo pipefail
74
+ if [[ "$*" == "repo view --json nameWithOwner,url" ]]; then
75
+ echo '{"nameWithOwner":"owner/repo","url":"https://github.com/owner/repo"}'
76
+ else
77
+ echo 'intentional lifecycle-test stop' >&2
78
+ exit 1
79
+ fi
80
+ `,
81
+ );
82
+ chmodSync(gh, 0o755);
83
+ if (previousPath === undefined) previousPath = process.env.PATH;
84
+ process.env.PATH = `${dir}:${process.env.PATH ?? ""}`;
85
+ return dir;
86
+ }
87
+
88
+ function createHarness(initialBranch: any[] = [], identity = session): Harness {
89
+ let nextId = 1;
90
+ const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
91
+ const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
92
+ const branch = [...initialBranch];
93
+ const notifications: string[] = [];
94
+ const sessionManager = {
95
+ getBranch: () => [...branch],
96
+ getSessionId: () => identity.id,
97
+ getHeader: () => ({ type: "session", id: identity.id, timestamp: identity.startedAt, cwd: "/tmp" }),
98
+ getLeafEntry: () => branch.at(-1),
99
+ };
100
+ const ctx = {
101
+ cwd: installFakeGh(),
102
+ mode: "json",
103
+ isProjectTrusted: () => false,
104
+ sessionManager,
105
+ ui: { notify: (message: string) => notifications.push(message) },
106
+ };
107
+ const pi = {
108
+ on: (name: string, handler: (event: any, ctx: any) => any) => {
109
+ const registered = handlers.get(name) ?? [];
110
+ registered.push(handler);
111
+ handlers.set(name, registered);
112
+ },
113
+ registerCommand: (name: string, options: { handler: (args: string, ctx: any) => Promise<void> }) => {
114
+ commands.set(name, options.handler);
115
+ },
116
+ appendEntry: (customType: string, data: unknown) => {
117
+ branch.push({ type: "custom", id: `custom-${nextId++}`, customType, data });
118
+ },
119
+ };
120
+ reviewTable(pi as any);
121
+ return {
122
+ handlers,
123
+ commands,
124
+ branch,
125
+ notifications,
126
+ ctx,
127
+ appendMessage(message: any, id = `message-${nextId++}`) {
128
+ const entry = { type: "message", id, message };
129
+ branch.push(entry);
130
+ return entry;
131
+ },
132
+ async emit(name: string, event: any) {
133
+ const results = [];
134
+ for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
135
+ return results;
136
+ },
137
+ };
138
+ }
139
+
140
+ function persistedInlineReview(identity = session): any {
141
+ const cache = new CompletedReviewCache();
142
+ const record = cache.remember(review, invocation, repository);
143
+ return cache.persist(record, identity);
144
+ }
145
+
146
+ describe("completed review extension lifecycle", () => {
147
+ test("persists a reference before publishing after Pi stores exact assistant JSON", async () => {
148
+ const harness = createHarness();
149
+ await harness.emit("input", { text: "/pr-review 7 --comment" });
150
+ const message = {
151
+ role: "assistant",
152
+ stopReason: "stop",
153
+ content: [{ type: "text", text: JSON.stringify(review) }],
154
+ };
155
+ await harness.emit("message_end", { message });
156
+ const assistantEntry = harness.appendMessage(message, "assistant-review");
157
+ await harness.emit("turn_end", { message, toolResults: [] });
158
+
159
+ const persisted = harness.branch.findLast(
160
+ (entry) => entry.type === "custom" && entry.customType === COMPLETED_REVIEW_ENTRY_TYPE,
161
+ );
162
+ expect(persisted?.data.reviewEntryId).toBe(assistantEntry.id);
163
+ expect(persisted?.data.review).toBeUndefined();
164
+ expect(harness.notifications.some((message) => message.includes("PR review publish failed"))).toBeTrue();
165
+ });
166
+
167
+ test("restores on session_start, clears on tree navigation, and scopes reused IDs by header", async () => {
168
+ const persisted = persistedInlineReview();
169
+ const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
170
+ const harness = createHarness([cacheEntry]);
171
+ await harness.emit("session_start", { reason: "reload" });
172
+ await harness.commands.get("pr-review-publish")!("7 --allow-stale", harness.ctx);
173
+ expect(harness.notifications.some((message) => message.includes("No completed review"))).toBeFalse();
174
+
175
+ harness.branch.splice(0);
176
+ harness.notifications.splice(0);
177
+ await harness.emit("session_tree", { newLeafId: null, oldLeafId: "cache" });
178
+ expect(harness.branch.at(-1)?.customType).toBe(COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE);
179
+ await harness.commands.get("pr-review-publish")!("7 --allow-stale", harness.ctx);
180
+ expect(harness.notifications.some((message) => message.includes("No completed review"))).toBeTrue();
181
+
182
+ const reusedId = createHarness([cacheEntry], { id: session.id, startedAt: "2026-07-14T00:00:00.000Z" });
183
+ await reusedId.emit("session_start", { reason: "fork" });
184
+ await reusedId.commands.get("pr-review-publish")!("7 --allow-stale", reusedId.ctx);
185
+ expect(reusedId.notifications.some((message) => message.includes("No completed review"))).toBeTrue();
186
+ });
187
+
188
+ test("does not append a redundant anchor for summarized tree navigation", async () => {
189
+ const harness = createHarness();
190
+ await harness.emit("session_tree", {
191
+ newLeafId: "summary",
192
+ oldLeafId: "old",
193
+ summaryEntry: { type: "branch_summary", id: "summary" },
194
+ });
195
+ expect(harness.branch.some((entry) => entry.customType === COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE)).toBeFalse();
196
+ });
197
+ });
@@ -11,6 +11,7 @@ import {
11
11
  classifyAssistantCompletion,
12
12
  canonicalReviewMarker,
13
13
  collectFoldedComments,
14
+ COMPLETED_REVIEW_ENTRY_TYPE,
14
15
  CompletedReviewCache,
15
16
  decideReviewPublication,
16
17
  containsReservedReviewMarker,
@@ -24,6 +25,7 @@ import {
24
25
  planHeadPublication,
25
26
  publishPullReview,
26
27
  resolveAutoPostSetting,
28
+ restoreCompletedReviewBranch,
27
29
  ReviewInvocationGate,
28
30
  shouldPublishReview,
29
31
  validateInlineComments,
@@ -80,6 +82,7 @@ const changedFiles = [
80
82
  ];
81
83
 
82
84
  const autoOff = resolveAutoPostSetting({ autoPostReviews: false });
85
+ const sessionA = { id: "session-a", startedAt: "2026-07-13T00:00:00.000Z" };
83
86
 
84
87
  describe("automatic posting configuration", () => {
85
88
  test("defaults to disabled", () => {
@@ -292,7 +295,7 @@ describe("publish-only completed review command", () => {
292
295
 
293
296
  test("retains the latest completed review under its repository and PR", () => {
294
297
  const cache = new CompletedReviewCache();
295
- const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false };
298
+ const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
296
299
  const repository = { hostname: "github.com", repository: "owner/repo" };
297
300
  cache.remember(review, invocation, repository);
298
301
  expect(cache.get(7, repository)).toEqual({ review, invocation, repository });
@@ -302,6 +305,76 @@ describe("publish-only completed review command", () => {
302
305
  expect(cache.get(7, repository)).toBeUndefined();
303
306
  });
304
307
 
308
+ test("restores only validated state from the same Pi session instance", () => {
309
+ const cache = new CompletedReviewCache();
310
+ const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
311
+ const repository = { hostname: "github.com", repository: "owner/repo" };
312
+ const record = cache.remember(review, invocation, repository);
313
+ const persisted = cache.persist(record, sessionA);
314
+ const restored = new CompletedReviewCache();
315
+ expect(restored.restore(persisted, sessionA)).toBeTrue();
316
+ expect(restored.get(7, repository)).toEqual({ review, invocation, repository });
317
+ expect(
318
+ restored.restore(persisted, { id: sessionA.id, startedAt: "2026-07-14T00:00:00.000Z" }),
319
+ ).toBeFalse();
320
+ expect(restored.restore({ ...persisted, schemaVersion: 1 }, sessionA)).toBeFalse();
321
+ expect(
322
+ restored.restore(
323
+ { ...persisted, repository: { hostname: "invalid host", repository: "owner/repo" } },
324
+ sessionA,
325
+ ),
326
+ ).toBeFalse();
327
+ });
328
+
329
+ test("restores referenced reviews without duplicating raw JSON", () => {
330
+ const cache = new CompletedReviewCache();
331
+ const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
332
+ const repository = { hostname: "github.com", repository: "owner/repo" };
333
+ const record = cache.remember(review, invocation, repository);
334
+ const persisted = cache.persist(record, sessionA, "review-message", review);
335
+ expect(persisted.review).toBeUndefined();
336
+ expect(cache.persist(record, sessionA, "wrong-message", { ...review, overview: "Different" }).review).toEqual(review);
337
+ const branch = [
338
+ {
339
+ type: "message",
340
+ id: "review-message",
341
+ message: { role: "assistant", content: [{ type: "text", text: JSON.stringify(review) }] },
342
+ },
343
+ { type: "custom", id: "cache-entry", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted },
344
+ ];
345
+ const restored = new CompletedReviewCache();
346
+ expect(restoreCompletedReviewBranch(restored, branch, sessionA)).toBe(1);
347
+ expect(restored.get(7, repository)).toEqual({ review, invocation, repository });
348
+ });
349
+
350
+ test("rebuilds cache state for reloads and session-tree navigation", () => {
351
+ const cache = new CompletedReviewCache();
352
+ const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
353
+ const repository = { hostname: "github.com", repository: "owner/repo" };
354
+ const record = cache.remember(review, invocation, repository);
355
+ const persisted = cache.persist(record, sessionA);
356
+ const branch = [{ type: "custom", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted }];
357
+
358
+ expect(restoreCompletedReviewBranch(cache, branch, sessionA)).toBe(1);
359
+ expect(cache.get(7, repository)).toBeDefined();
360
+ expect(restoreCompletedReviewBranch(cache, [], sessionA)).toBe(0);
361
+ expect(cache.get(7, repository)).toBeUndefined();
362
+ expect(
363
+ restoreCompletedReviewBranch(cache, branch, { id: sessionA.id, startedAt: "different-instance" }),
364
+ ).toBe(0);
365
+ expect(cache.get(7, repository)).toBeUndefined();
366
+ });
367
+
368
+ test("does not retry publication without the repository binding used for caching", () => {
369
+ const extension = readFileSync(new URL("../extensions/review-table.ts", import.meta.url), "utf8");
370
+ const publisher = extension.slice(
371
+ extension.indexOf("async function maybePublishReview"),
372
+ extension.indexOf("export default function"),
373
+ );
374
+ expect(publisher).toContain("if (!expectedRepository)");
375
+ expect(publisher).toContain("no publish-only cache is available");
376
+ });
377
+
305
378
  test("keeps stale protection by default and degrades an explicit override", () => {
306
379
  const reviewed = "a".repeat(40);
307
380
  const current = "b".repeat(40);
@@ -166,8 +166,14 @@ describe("extension telemetry boundaries", () => {
166
166
  expect(renderer).toContain('pi.appendEntry("pr-review-telemetry", telemetry)');
167
167
  expect(renderer).toContain("telemetryTracker.pauseForConfirmation()");
168
168
  expect(renderer).toContain("telemetryTracker.resumeAfterConfirmation()");
169
- expect(renderer.indexOf('persistTelemetry("terminal_response")')).toBeLessThan(
170
- renderer.indexOf("await maybePublishReview"),
169
+ const turnEnd = renderer.slice(
170
+ renderer.indexOf('pi.on("turn_end"'),
171
+ renderer.indexOf('pi.on("message_end"'),
171
172
  );
173
+ const messageEnd = renderer.slice(renderer.indexOf('pi.on("message_end"'));
174
+ expect(messageEnd.indexOf('persistTelemetry("terminal_response")')).toBeLessThan(
175
+ messageEnd.indexOf("pendingCompletion ="),
176
+ );
177
+ expect(turnEnd.indexOf("pi.appendEntry(")).toBeLessThan(turnEnd.indexOf("await maybePublishReview"));
172
178
  });
173
179
  });