@effect-agent/pr-review 0.1.0-beta.12 → 0.1.0-beta.14

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.
Files changed (39) hide show
  1. package/README.md +53 -14
  2. package/dist/action.d.mts +13 -3
  3. package/dist/action.mjs +35 -5
  4. package/dist/action.mjs.map +1 -1
  5. package/dist/cli.mjs +2 -2
  6. package/dist/{fan-out-TrA9EUCr.d.mts → fan-out-DBHPcJwC.d.mts} +133 -35
  7. package/dist/{github-5TCFrxfX.mjs → github-mmanX6hk.mjs} +204 -40
  8. package/dist/github-mmanX6hk.mjs.map +1 -0
  9. package/dist/index.d.mts +90 -4
  10. package/dist/index.mjs +4 -3
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/logging-Q4j0oub-.mjs +75 -0
  13. package/dist/logging-Q4j0oub-.mjs.map +1 -0
  14. package/dist/{providers-DobNWMUn.mjs → providers-DD2GdrXQ.mjs} +387 -26
  15. package/dist/providers-DD2GdrXQ.mjs.map +1 -0
  16. package/dist/testing.d.mts +2 -1
  17. package/dist/testing.mjs +23 -15
  18. package/dist/testing.mjs.map +1 -1
  19. package/package.json +2 -2
  20. package/src/action.ts +60 -2
  21. package/src/index.ts +2 -0
  22. package/src/internal/action-entry.ts +2 -0
  23. package/src/internal/coverage.ts +42 -4
  24. package/src/internal/diff.ts +59 -0
  25. package/src/internal/fan-out.ts +23 -3
  26. package/src/internal/fingerprint.ts +1 -1
  27. package/src/internal/fixtures.ts +15 -4
  28. package/src/internal/github-env.ts +8 -1
  29. package/src/internal/github.ts +73 -12
  30. package/src/internal/logging.ts +124 -0
  31. package/src/internal/progress.ts +433 -0
  32. package/src/internal/render.ts +251 -19
  33. package/src/internal/retirement.ts +5 -1
  34. package/src/internal/review-agent.ts +84 -10
  35. package/src/internal/review-state.ts +21 -1
  36. package/src/internal/review-units.ts +17 -11
  37. package/src/internal/run.ts +33 -2
  38. package/dist/github-5TCFrxfX.mjs.map +0 -1
  39. package/dist/providers-DobNWMUn.mjs.map +0 -1
package/src/action.ts CHANGED
@@ -7,6 +7,8 @@ import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./
7
7
  import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
8
8
  import { readGitHubEvent, resolveReviewTarget, gitHubReviewLayers } from "./internal/github-env.ts";
9
9
  import { PriorReviews } from "./internal/github.ts";
10
+ import { compactReviewLoggingLayer } from "./internal/logging.ts";
11
+ import { ReviewProgressReporter } from "./internal/progress.ts";
10
12
  import {
11
13
  anthropicClientLayer,
12
14
  DEFAULT_PROVIDER,
@@ -92,6 +94,7 @@ export interface ResolvedActionInputs {
92
94
  readonly failOn: FailOnPolicy;
93
95
  readonly skipUnchanged: boolean;
94
96
  readonly retireStaleReviews: boolean;
97
+ readonly progressComment: boolean;
95
98
  }
96
99
 
97
100
  /** Read the PR_REVIEW_* input surface (all optional, all defaulted). */
@@ -135,6 +138,9 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
135
138
  const retireStaleReviews = yield* Config.boolean("PR_REVIEW_RETIRE_STALE_REVIEWS").pipe(
136
139
  Config.withDefault(true),
137
140
  );
141
+ const progressComment = yield* Config.boolean("PR_REVIEW_PROGRESS_COMMENT").pipe(
142
+ Config.withDefault(true),
143
+ );
138
144
  return {
139
145
  provider,
140
146
  model: Option.getOrUndefined(model),
@@ -154,6 +160,7 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
154
160
  failOn,
155
161
  skipUnchanged,
156
162
  retireStaleReviews,
163
+ progressComment,
157
164
  } satisfies ResolvedActionInputs;
158
165
  });
159
166
 
@@ -408,6 +415,13 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
408
415
  readonly priorReviews?: PriorReviews["Service"] | undefined;
409
416
  /** Retire marker-bearing prior reviews after a successful post (default true). */
410
417
  readonly retireStaleReviews?: boolean | undefined;
418
+ /**
419
+ * Maintain one sticky "review in progress" issue comment, updated in
420
+ * place when the run settles. Default false here for custom-harness
421
+ * compatibility; the packaged action enables it by default. Dry runs
422
+ * (`post: false`) never post progress.
423
+ */
424
+ readonly progressComment?: boolean | undefined;
411
425
  } = {},
412
426
  ) =>
413
427
  Effect.gen(function* () {
@@ -561,7 +575,37 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
561
575
  `Reviewing ${target.repository}#${target.number} (${options.post === false ? "dry run" : "posting"})...`,
562
576
  );
563
577
  const runUrl = yield* resolveRunUrl();
564
- const reviewEffect = reviewer.run({ post: options.post ?? true, runUrl });
578
+ // Progress is a cosmetic, fail-open narration surface: it says a run is
579
+ // working the moment execution starts and is overwritten in place when
580
+ // the run settles. It never gates or delays the review itself.
581
+ const progress =
582
+ options.progressComment === true && options.post !== false
583
+ ? Option.some(yield* ReviewProgressReporter)
584
+ : Option.none<ReviewProgressReporter["Service"]>();
585
+ if (Option.isSome(progress)) {
586
+ const source = yield* PullRequestSource;
587
+ const headMetadata = yield* source.metadata.pipe(Effect.orElseSucceed(() => undefined));
588
+ yield* progress.value.begin({
589
+ headSha: headMetadata?.headSha,
590
+ reviewMode: selection?.mode,
591
+ reviewReason: selection?.reason,
592
+ filesInScope: selection?.files.length,
593
+ modelLabel: options.modelLabel,
594
+ runUrl,
595
+ });
596
+ }
597
+ const runReview = reviewer.run({ post: options.post ?? true, runUrl });
598
+ const reviewEffect = Option.isSome(progress)
599
+ ? runReview.pipe(
600
+ Effect.tapCause(() =>
601
+ progress.value.settle({
602
+ outcome: "failed",
603
+ runUrl,
604
+ modelLabel: options.modelLabel,
605
+ }),
606
+ ),
607
+ )
608
+ : runReview;
565
609
  const outcome = yield* selection === undefined
566
610
  ? reviewEffect
567
611
  : reviewEffect.pipe(
@@ -597,6 +641,17 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
597
641
  }
598
642
  }
599
643
  const check = concludeReviewOutcome(outcome);
644
+ if (Option.isSome(progress)) {
645
+ yield* progress.value.settle({
646
+ outcome: "reviewed",
647
+ conclusion: check.conclusion,
648
+ verdict: outcome.review.verdict,
649
+ inlineComments: outcome.plan.comments.length,
650
+ reviewUrl: outcome.published?.url,
651
+ runUrl,
652
+ modelLabel: options.modelLabel,
653
+ });
654
+ }
600
655
  yield* writeActionOutputs(outcomeOutputs(outcome, check.conclusion));
601
656
  yield* writeStepSummary(outcomeSummary(outcome, options.modelLabel, check.conclusion));
602
657
  if (check.conclusion !== "success") {
@@ -651,6 +706,7 @@ export const reviewActionProgram = Effect.gen(function* () {
651
706
  skipUnchanged: inputs.skipUnchanged,
652
707
  reviewMode: inputs.reviewMode,
653
708
  retireStaleReviews: inputs.retireStaleReviews,
709
+ progressComment: inputs.progressComment,
654
710
  modelLabel,
655
711
  };
656
712
  if (inputs.provider === "anthropic") {
@@ -683,7 +739,9 @@ export const main = (): void =>
683
739
  ),
684
740
  ),
685
741
  Effect.scoped,
686
- Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer)),
742
+ Effect.provide(
743
+ Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer, compactReviewLoggingLayer),
744
+ ),
687
745
  ),
688
746
  { disableErrorReporting: true },
689
747
  );
package/src/index.ts CHANGED
@@ -10,7 +10,9 @@ export * from "./internal/fingerprint.ts";
10
10
  export * from "./internal/github.ts";
11
11
  export * from "./internal/github-env.ts";
12
12
  export * from "./internal/ignore.ts";
13
+ export * from "./internal/logging.ts";
13
14
  export * from "./internal/profiles.ts";
15
+ export * from "./internal/progress.ts";
14
16
  export * from "./internal/providers.ts";
15
17
  export * from "./internal/render.ts";
16
18
  export * from "./internal/retirement.ts";
@@ -26,6 +26,8 @@ const INPUT_TO_ENV: ReadonlyArray<readonly [input: string, env: string]> = [
26
26
  ["INPUT_FAIL-ON", "PR_REVIEW_FAIL_ON"],
27
27
  ["INPUT_SKIP-UNCHANGED", "PR_REVIEW_SKIP_UNCHANGED"],
28
28
  ["INPUT_RETIRE-STALE-REVIEWS", "PR_REVIEW_RETIRE_STALE_REVIEWS"],
29
+ ["INPUT_PROGRESS-COMMENT", "PR_REVIEW_PROGRESS_COMMENT"],
30
+ ["INPUT_LOG-LEVEL", "PR_REVIEW_LOG_LEVEL"],
29
31
  ["INPUT_STATE-SECRET", "PR_REVIEW_STATE_SECRET"],
30
32
  ["INPUT_REVIEW-AUTHOR", "PR_REVIEW_AUTHOR_LOGIN"],
31
33
  ["INPUT_OPENAI-API-KEY", "OPENAI_API_KEY"],
@@ -2,8 +2,9 @@ import { Option, Schema } from "effect";
2
2
  import type { RunEvent } from "effect-agent";
3
3
 
4
4
  import type { ChangedFile } from "./diff.ts";
5
+ import { isReviewableFile } from "./diff.ts";
5
6
  import { FileReviewDelegationFailure, FileReviewRequest, FileReviewUnitResult } from "./fan-out.ts";
6
- import { FileDiffQuery } from "./review-agent.ts";
7
+ import { FileDiffQuery, type WalkthroughEntry } from "./review-agent.ts";
7
8
  import { planReviewUnits } from "./review-units.ts";
8
9
 
9
10
  // ---------------------------------------------------------------------------
@@ -95,7 +96,7 @@ const flatCoverage = (
95
96
  if (trace.succeeded.has(toolCallId)) reviewed.add(query.value.path);
96
97
  if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
97
98
  }
98
- const undiffable = files.filter((file) => file.patch === undefined).map((file) => file.path);
99
+ const undiffable = files.filter((file) => !isReviewableFile(file)).map((file) => file.path);
99
100
  const unreviewed = requiredPaths.filter(
100
101
  (path) => !reviewed.has(path) || undiffable.includes(path) || failedPaths.has(path),
101
102
  );
@@ -104,7 +105,9 @@ const flatCoverage = (
104
105
  reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
105
106
  }
106
107
  if (undiffable.length > 0) {
107
- reasons.push(boundedListReason("required paths have no textual diff", undiffable));
108
+ reasons.push(
109
+ boundedListReason("required paths have no reviewable diff or bounded text", undiffable),
110
+ );
108
111
  }
109
112
  if (failedPaths.size > 0) {
110
113
  reasons.push(boundedListReason("diff reads failed", failedPaths));
@@ -196,7 +199,12 @@ const fanOutCoverage = (
196
199
  reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
197
200
  }
198
201
  if (plan.undiffablePaths.length > 0) {
199
- reasons.push(boundedListReason("required paths have no textual diff", plan.undiffablePaths));
202
+ reasons.push(
203
+ boundedListReason(
204
+ "required paths have no reviewable diff or bounded text",
205
+ plan.undiffablePaths,
206
+ ),
207
+ );
200
208
  }
201
209
  if (plan.unassignedPaths.length > 0) {
202
210
  reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
@@ -219,6 +227,36 @@ const fanOutCoverage = (
219
227
  });
220
228
  };
221
229
 
230
+ /**
231
+ * Host-verified per-file summaries from the fan-out run's Tool events: for
232
+ * every successfully settled delegation, the child-reported `fileSummaries`
233
+ * whose paths belong to that invocation's requested unit. This is the
234
+ * declassification check `projectResult` cannot perform itself (it never sees
235
+ * the request): a child assigned file A cannot smuggle a summary for changed
236
+ * file B into the merged walkthrough, and a coordinator cannot invent or edit
237
+ * entries — only exact child-reported, in-unit summaries survive.
238
+ */
239
+ export const collectUnitFileSummaries = (
240
+ events: ReadonlyArray<RunEvent>,
241
+ ): ReadonlyArray<WalkthroughEntry> => {
242
+ const trace = toolTrace(events);
243
+ const entries: Array<WalkthroughEntry> = [];
244
+ for (const [toolCallId, declaration] of trace.declared) {
245
+ if (declaration.toolName !== "delegate_file_review") continue;
246
+ const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
247
+ if (Option.isNone(request)) continue;
248
+ const success = trace.succeeded.get(toolCallId);
249
+ if (success === undefined || trace.failed.has(toolCallId)) continue;
250
+ const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
251
+ if (Option.isNone(result) || result.value.unitId !== request.value.unitId) continue;
252
+ const assigned = new Set(request.value.paths);
253
+ for (const entry of result.value.fileSummaries ?? []) {
254
+ if (assigned.has(entry.path)) entries.push(entry);
255
+ }
256
+ }
257
+ return entries;
258
+ };
259
+
222
260
  /** Assess one settled run without trusting its prose summary or verdict. */
223
261
  export const assessReviewCoverage = (input: {
224
262
  readonly shape: ReviewShape;
@@ -31,8 +31,67 @@ export class ChangedFile extends Schema.Class<ChangedFile>("@effect-agent/pr-rev
31
31
  previousPath: Schema.optionalKey(ChangedPath),
32
32
  /** Unified-diff hunks; absent for binary or oversized files. */
33
33
  patch: Schema.optionalKey(Schema.String),
34
+ /**
35
+ * Bounded UTF-8 content used only when the provider omitted `patch`.
36
+ * Modified files require both sides; additions require head content and
37
+ * deletions require base content. These values are review evidence, never
38
+ * GitHub inline-comment anchors.
39
+ */
40
+ reviewBaseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
41
+ reviewHeadContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
34
42
  }) {}
35
43
 
44
+ /** Complete rendered fallback evidence must fit one ordinary model context. */
45
+ export const MAX_REVIEW_CONTENT_CHARS = 220_000;
46
+
47
+ /**
48
+ * Render complete patchless evidence, or refuse it when a required side is
49
+ * absent or B/H annotation would exceed the model-facing bound. Callers use
50
+ * this same value for planning and tool output so truncated fallback evidence
51
+ * can never count as complete coverage.
52
+ */
53
+ export const renderReviewContent = (file: ChangedFile): string | undefined => {
54
+ if (file.patch !== undefined) return undefined;
55
+ const includeBase = file.status !== "added";
56
+ const includeHead = file.status !== "removed";
57
+ const sections: Array<string> = [
58
+ "[GitHub omitted the unified diff. B/H lines below are bounded full-file review content, not valid inline-comment anchors. Report defects from this evidence as non-anchored concerns.]",
59
+ ];
60
+ let renderedLength = sections[0]?.length ?? 0;
61
+ const append = (part: string): boolean => {
62
+ const nextLength = renderedLength + 1 + part.length;
63
+ if (nextLength > MAX_REVIEW_CONTENT_CHARS) return false;
64
+ sections.push(part);
65
+ renderedLength = nextLength;
66
+ return true;
67
+ };
68
+ const appendSide = (side: "B" | "H", header: string, content: string): boolean => {
69
+ if (!append(header)) return false;
70
+ const lines = content.split("\n");
71
+ for (let index = 0; index < lines.length; index += 1) {
72
+ if (!append(`${side}${index + 1} ${lines[index] ?? ""}`)) return false;
73
+ }
74
+ return true;
75
+ };
76
+ if (includeBase) {
77
+ if (file.reviewBaseContent === undefined) return undefined;
78
+ if (!appendSide("B", "[BASE VERSION]", file.reviewBaseContent)) return undefined;
79
+ }
80
+ if (includeHead) {
81
+ if (file.reviewHeadContent === undefined) return undefined;
82
+ if (!appendSide("H", "[HEAD VERSION]", file.reviewHeadContent)) return undefined;
83
+ }
84
+ return sections.join("\n");
85
+ };
86
+
87
+ /** Whether complete patchless evidence fits the model-facing review bound. */
88
+ export const hasReviewableContent = (file: ChangedFile): boolean =>
89
+ renderReviewContent(file) !== undefined;
90
+
91
+ /** Whether the reviewer has either a real patch or bounded textual fallback evidence. */
92
+ export const isReviewableFile = (file: ChangedFile): boolean =>
93
+ file.patch !== undefined || hasReviewableContent(file);
94
+
36
95
  /** One parsed line of a unified diff, with both coordinate systems. */
37
96
  export interface PatchLine {
38
97
  readonly kind: "context" | "add" | "del";
@@ -6,6 +6,7 @@ import {
6
6
  SubagentPolicy,
7
7
  SubagentRuntime,
8
8
  ToolExecutionClass,
9
+ ToolResultBounds,
9
10
  type RuntimeBinding,
10
11
  } from "effect-agent";
11
12
  import { Tool, Toolkit } from "effect/unstable/ai";
@@ -15,13 +16,16 @@ import {
15
16
  clampMaxFindings,
16
17
  CodeReview,
17
18
  MAX_CONCERNS,
19
+ MAX_WALKTHROUGH_SUMMARY_CHARS,
18
20
  ReadFile,
19
21
  ReadFileDiff,
20
22
  readFileDiffHandler,
21
23
  readFileHandler,
24
+ REVIEW_TOOL_RESULT_MAX_BYTES,
22
25
  ReviewConcern,
23
26
  ReviewFinding,
24
27
  ReviewMission,
28
+ WalkthroughEntry,
25
29
  } from "./review-agent.ts";
26
30
  import {
27
31
  MAX_REVIEW_UNITS,
@@ -92,6 +96,10 @@ export class FileReviewReport extends Schema.Class<FileReviewReport>(
92
96
  concerns: Schema.optionalKey(
93
97
  Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
94
98
  ),
99
+ /** One-sentence per-file change summaries for the merged walkthrough. */
100
+ fileSummaries: Schema.optionalKey(
101
+ Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
102
+ ),
95
103
  }) {}
96
104
 
97
105
  /**
@@ -120,12 +128,13 @@ export const makeFileReviewerInstructions =
120
128
  `You are a code reviewer for one unit of a pull request: unit ${brief.unitId}, covering exactly these changed files: ${brief.paths.join(", ")}. Focus: ${brief.focus}.`,
121
129
  ...staticGuidanceLines(options.guidance),
122
130
  "Work in this order:",
123
- "1. Call read_file_diff for every file in your unit. In its output, only lines marked R<number> exist in the new version; those numbers are the only valid values for startLine and endLine. Never anchor a finding to a removed (-) line.",
131
+ "1. Call read_file_diff for every file in your unit. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
124
132
  "2. Call read_file when you need surrounding context the diff does not show. ONLY files in the changeset are readable: a request for any other path (an import, a neighbor, a config) returns a failed result — do not retry it; reason from the diff instead and note the gap in your report when it matters.",
125
133
  "3. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
126
134
  "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
127
135
  "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
128
- `4. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>}.`,
136
+ `4. For every file in your unit, write one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file for a reader scanning the pull request, never a line-by-line restatement.`,
137
+ `5. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>, "fileSummaries": <array, OPTIONAL: [{"path": <string, a file in your unit>, "summary": <string, the step-4 sentence>}], one entry per file in your unit>}.`,
129
138
  `Report at most ${MAX_CHILD_FINDINGS} findings and at most ${MAX_CHILD_CONCERNS} concerns; prefer the most important ones. An empty findings array is a valid report. Never report on files outside your unit. Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.`,
130
139
  ].join("\n");
131
140
 
@@ -137,10 +146,15 @@ export const defaultFileReviewerPolicy = AgentPolicy.make({
137
146
  maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
138
147
  maxDuration: "6 minutes",
139
148
  toolConcurrency: 2,
149
+ // Same rationale as the flat reviewer's bound: read refusals are
150
+ // model-visible results, and one parallel batch of out-of-unit probes must
151
+ // not kill the child before it has seen a single refusal.
152
+ repeatedFailureLimit: 12,
140
153
  tokenBudget: 200_000,
141
154
  // Bound one live prompt independently from cumulative usage. The engine
142
155
  // prunes old diff/file results before paying for a summary.
143
156
  contextTokenLimit: 150_000,
157
+ toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
144
158
  // Typed exhaustion, deliberately NOT the final-answer soft landing: a
145
159
  // review is a coverage claim, and a child whose reads were rejected could
146
160
  // still emit schema-valid findings — laundering budget exhaustion into
@@ -176,6 +190,10 @@ export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
176
190
  concerns: Schema.optionalKey(
177
191
  Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
178
192
  ),
193
+ /** One-sentence per-file change summaries for the merged walkthrough. */
194
+ fileSummaries: Schema.optionalKey(
195
+ Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
196
+ ),
179
197
  }) {}
180
198
 
181
199
  /**
@@ -284,7 +302,8 @@ export const makeFanOutReviewInstructions =
284
302
  '3. A delegation result with "_tag" is a FAILED unit. Never retry it; instead your summary MUST name it honestly, e.g. "unit-002 unreviewed: AgentPolicyError". The plan\'s undiffablePaths and unassignedPaths must also be named as not reviewed when present.',
285
303
  `4. Merge the successful units' findings: drop duplicates sharing the same path and line range keeping the most severe, rank blocking > important > nit, and keep at most ${maxFindings} findings. Drop bloat-shaped findings during the merge — defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies; children bias toward recommending changes, and a finding must be sound, correct, and worth acting on to survive.`,
286
304
  `5. Merge the units' concerns the same way: drop duplicates keeping the most severe, and keep at most ${MAX_CONCERNS}.`,
287
- '6. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>}. Copy findings and concerns verbatim from the delegation results; never invent or edit anchors.',
305
+ "6. Merge the units' fileSummaries into one walkthrough: copy each entry verbatim, one entry per file, dropping duplicate paths.",
306
+ '7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "category": <string, OPTIONAL>, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>, "walkthrough": <array, OPTIONAL: [{"path": <string>, "summary": <string>}], the merged fileSummaries>}. Copy findings (including "category" and "suggestion" when present), concerns, and walkthrough entries verbatim from the delegation results; never invent or edit anchors.',
288
307
  'Use verdict "request-changes" only when at least one finding or concern is "blocking". An empty findings array with verdict "approve" is a valid review when every unit succeeded and found nothing.',
289
308
  ].join("\n");
290
309
  };
@@ -364,6 +383,7 @@ const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefin
364
383
  unitId: report.unitId,
365
384
  findings: report.findings,
366
385
  ...(report.concerns !== undefined ? { concerns: report.concerns } : {}),
386
+ ...(report.fileSummaries !== undefined ? { fileSummaries: report.fileSummaries } : {}),
367
387
  }),
368
388
  ),
369
389
  policy: fileReviewPolicy,
@@ -57,7 +57,7 @@ const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
57
57
  files
58
58
  .map(
59
59
  (file) =>
60
- `${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch ?? ""}`,
60
+ `${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch ?? ""}${FIELD}${file.reviewBaseContent ?? ""}${FIELD}${file.reviewHeadContent ?? ""}`,
61
61
  )
62
62
  .sort()
63
63
  .join(RECORD);
@@ -28,6 +28,7 @@ import {
28
28
  /** One fixture file: its changeset entry plus optional head content. */
29
29
  export class FixtureFile extends Schema.Class<FixtureFile>("@effect-agent/pr-review/FixtureFile")({
30
30
  file: ChangedFile,
31
+ baseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
31
32
  headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
32
33
  }) {}
33
34
 
@@ -57,12 +58,21 @@ const requireChanged = (
57
58
  /** Deterministic `PullRequestSource` over one fixture pull request. */
58
59
  export const fixturePullRequestSourceLayer = (
59
60
  fixture: FixturePullRequest,
60
- ): Layer.Layer<PullRequestSource> =>
61
- Layer.succeed(PullRequestSource)(
61
+ ): Layer.Layer<PullRequestSource> => {
62
+ const files = fixture.files.map((entry) =>
63
+ entry.file.patch !== undefined
64
+ ? entry.file
65
+ : ChangedFile.make({
66
+ ...entry.file,
67
+ ...(entry.baseContent === undefined ? {} : { reviewBaseContent: entry.baseContent }),
68
+ ...(entry.headContent === undefined ? {} : { reviewHeadContent: entry.headContent }),
69
+ }),
70
+ );
71
+ return Layer.succeed(PullRequestSource)(
62
72
  PullRequestSource.of({
63
73
  metadata: Effect.succeed(fixture.metadata),
64
- changedFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),
65
- anchorFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),
74
+ changedFiles: Effect.succeed(files),
75
+ anchorFiles: Effect.succeed(files),
66
76
  readFile: (path) =>
67
77
  Effect.gen(function* () {
68
78
  const relative = yield* normalizeRepoRelativePath(path);
@@ -77,6 +87,7 @@ export const fixturePullRequestSourceLayer = (
77
87
  }),
78
88
  }),
79
89
  );
90
+ };
80
91
 
81
92
  /** In-memory publisher: records every plan and mints a deterministic receipt. */
82
93
  export const collectingReviewPublisherLayer = (
@@ -10,6 +10,8 @@ import {
10
10
  gitHubReviewPublisherLayer,
11
11
  gitHubReviewRetirementHostLayer,
12
12
  } from "./github.ts";
13
+ import type { ReviewProgressReporter } from "./progress.ts";
14
+ import { gitHubReviewProgressLayer } from "./progress.ts";
13
15
  import type { ReviewRetirementHost } from "./retirement.ts";
14
16
  import type { PullRequestSource } from "./source.ts";
15
17
 
@@ -110,7 +112,11 @@ export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (o
110
112
  export const gitHubReviewLayers = (
111
113
  target: ResolvedReviewTarget,
112
114
  ): Layer.Layer<
113
- PullRequestSource | ReviewPublisher | PriorReviews | ReviewRetirementHost,
115
+ | PullRequestSource
116
+ | ReviewPublisher
117
+ | PriorReviews
118
+ | ReviewRetirementHost
119
+ | ReviewProgressReporter,
114
120
  Config.ConfigError,
115
121
  HttpClient.HttpClient
116
122
  > =>
@@ -143,6 +149,7 @@ export const gitHubReviewLayers = (
143
149
  gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
144
150
  gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
145
151
  gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
152
+ gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
146
153
  );
147
154
  }),
148
155
  );
@@ -313,21 +313,13 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
313
313
  const metadata = yield* Effect.cached(
314
314
  fetchMetadata.pipe(Effect.provideService(HttpClient.HttpClient, client)),
315
315
  );
316
- const changedFiles = yield* Effect.cached(
316
+ const rawFiles = yield* Effect.cached(
317
317
  fetchFiles.pipe(Effect.provideService(HttpClient.HttpClient, client)),
318
318
  );
319
319
 
320
- const readFile = (path: string) =>
320
+ const readRepositoryFile = (path: string, ref: string) =>
321
321
  Effect.gen(function* () {
322
322
  const relative = yield* normalizeRepoRelativePath(path);
323
- const files = yield* changedFiles;
324
- if (!files.some((file) => file.path === relative)) {
325
- return yield* ReviewInputViolation.make({
326
- input: relative,
327
- reason: "Path is not part of this pull request's changeset.",
328
- });
329
- }
330
- const head = yield* metadata;
331
323
  const encodedPath = relative.split("/").map(encodeURIComponent).join("/");
332
324
  const response = yield* executeOk(
333
325
  "readFile",
@@ -336,12 +328,32 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
336
328
  `${target.apiUrl}/repos/${target.repository}/contents/${encodedPath}`,
337
329
  ).pipe(
338
330
  HttpClientRequest.accept("application/vnd.github.raw+json"),
339
- HttpClientRequest.setUrlParams({ ref: head.headSha }),
331
+ HttpClientRequest.setUrlParams({ ref }),
340
332
  ),
341
333
  target.token,
342
334
  ),
343
335
  ).pipe(Effect.provideService(HttpClient.HttpClient, client));
344
- const text = yield* response.text.pipe(Effect.mapError(failWith("readFile")));
336
+ const buffer = yield* response.arrayBuffer.pipe(Effect.mapError(failWith("readFile")));
337
+ if (buffer.byteLength > MAX_FILE_CHARS) {
338
+ return yield* ReviewInputViolation.make({
339
+ input: relative,
340
+ reason: `File is larger than the ${MAX_FILE_CHARS}-byte read bound.`,
341
+ });
342
+ }
343
+ const text = yield* Effect.try({
344
+ try: () => new TextDecoder("utf-8", { fatal: true }).decode(buffer),
345
+ catch: () =>
346
+ ReviewInputViolation.make({
347
+ input: relative,
348
+ reason: "File is not valid UTF-8 text.",
349
+ }),
350
+ });
351
+ if (text.includes("\u0000")) {
352
+ return yield* ReviewInputViolation.make({
353
+ input: relative,
354
+ reason: "File contains binary NUL bytes.",
355
+ });
356
+ }
345
357
  if (text.length > MAX_FILE_CHARS) {
346
358
  return yield* ReviewInputViolation.make({
347
359
  input: relative,
@@ -351,6 +363,55 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
351
363
  return text;
352
364
  });
353
365
 
366
+ const changedFiles = yield* Effect.cached(
367
+ Effect.gen(function* () {
368
+ const [files, pullRequest] = yield* Effect.all([rawFiles, metadata]);
369
+ return yield* Effect.forEach(
370
+ files,
371
+ (file) => {
372
+ if (file.patch !== undefined) return Effect.succeed(file);
373
+ const basePath = file.previousPath ?? file.path;
374
+ const base =
375
+ file.status === "added"
376
+ ? Effect.succeed(Option.none<string>())
377
+ : readRepositoryFile(basePath, pullRequest.baseSha ?? pullRequest.baseRef).pipe(
378
+ Effect.option,
379
+ );
380
+ const head =
381
+ file.status === "removed"
382
+ ? Effect.succeed(Option.none<string>())
383
+ : readRepositoryFile(file.path, pullRequest.headSha).pipe(Effect.option);
384
+ return Effect.all({ base, head }).pipe(
385
+ Effect.map(({ base, head }) =>
386
+ ChangedFile.make({
387
+ ...file,
388
+ ...(Option.isSome(base) ? { reviewBaseContent: base.value } : {}),
389
+ ...(Option.isSome(head) ? { reviewHeadContent: head.value } : {}),
390
+ }),
391
+ ),
392
+ );
393
+ },
394
+ { concurrency: 4 },
395
+ );
396
+ }),
397
+ );
398
+
399
+ const readFile = (path: string) =>
400
+ Effect.gen(function* () {
401
+ const relative = yield* normalizeRepoRelativePath(path);
402
+ const files = yield* changedFiles;
403
+ const file = files.find((candidate) => candidate.path === relative);
404
+ if (file === undefined) {
405
+ return yield* ReviewInputViolation.make({
406
+ input: relative,
407
+ reason: "Path is not part of this pull request's changeset.",
408
+ });
409
+ }
410
+ if (file.reviewHeadContent !== undefined) return file.reviewHeadContent;
411
+ const head = yield* metadata;
412
+ return yield* readRepositoryFile(relative, head.headSha);
413
+ });
414
+
354
415
  return PullRequestSource.of({ metadata, changedFiles, anchorFiles: changedFiles, readFile });
355
416
  }),
356
417
  );