gentle-pi 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
6
  import { createNodeExecFileAdapter } from "../lib/native-review-cli.ts";
7
+ import { INPROCESS_REVIEWER_FAILURE, type InProcessReviewerOutcome, type InProcessReviewerRequest, type InProcessReviewerDeps, runInProcessReviewer } from "../lib/inprocess-reviewer.ts";
7
8
  import {
8
9
  REVIEW_HOST_RELAY_FAILURE,
9
- REVIEW_HOST_RELAY_PI_ARGV,
10
10
  REVIEW_HOST_RELAY_PI_TIMEOUT_ENV,
11
11
  REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS,
12
12
  REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS,
@@ -27,14 +27,26 @@ import {
27
27
  type ReviewHostRelayPreparedResult,
28
28
  type ReviewHostRelayRequest,
29
29
  } from "../lib/review-host-relay.ts";
30
+ import * as reviewHostRelayModule from "../lib/review-host-relay.ts";
30
31
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
31
32
  import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
32
33
 
34
+ // gentle-pi#311 P2: the reviewer transport this file exercises no longer
35
+ // spawns a pi child. `runReviewHostRelaySlot`/`prepareReviewHostRelaySlot`
36
+ // take an injectable `runReviewer` (defaulting to the real
37
+ // `runInProcessReviewer`), so every test below fakes that seam instead of a
38
+ // fake `pi` executable on disk. `lib/inprocess-reviewer.ts`'s own tests own
39
+ // the completion's internal behavior (model resolution, auth, thinking
40
+ // mapping, text extraction); this file owns the relay's mapping of that
41
+ // completion's outcome onto a typed `ReviewHostRelayError`, plus everything
42
+ // unrelated to the reviewer transport (materialize, submission, admission,
43
+ // slot detection) which stayed exactly as it was.
44
+
33
45
  // ---------------------------------------------------------------------------
34
- // Fake binaries. Following the repo's fake-executable idiom (shell/git
35
- // wrappers in related review tests), these are
36
- // shebang scripts written into a scratch directory; node scripts are used so
37
- // binary-unsafe bytes survive verbatim.
46
+ // Fake gentle-ai binary. Following the repo's fake-executable idiom (shell/git
47
+ // wrappers in related review tests), this is a shebang script written into a
48
+ // scratch directory; a node script is used so binary-unsafe bytes survive
49
+ // verbatim.
38
50
  // ---------------------------------------------------------------------------
39
51
 
40
52
  const FAKE_GENTLE_AI = `#!/usr/bin/env node
@@ -94,52 +106,10 @@ process.stderr.write("unexpected fake gentle-ai invocation\\n");
94
106
  process.exit(9);
95
107
  `;
96
108
 
97
- const FAKE_PI = `#!/usr/bin/env node
98
- const fs = require("node:fs");
99
- const path = require("node:path");
100
- const argv = process.argv.slice(2);
101
- if (process.env.RELAY_FAKE_PI_LOG) fs.appendFileSync(process.env.RELAY_FAKE_PI_LOG, JSON.stringify({ argv, cwd: process.cwd(), entries: fs.readdirSync(process.cwd()), contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
102
- const chunks = [];
103
- process.stdin.on("data", (chunk) => chunks.push(chunk));
104
- process.stdin.on("end", () => {
105
- const stdin = Buffer.concat(chunks);
106
- if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
107
- const finish = () => {
108
- const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
109
- if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
110
- if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
111
- if (mode === "empty") process.exit(0);
112
- if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
113
- process.stderr.write("pi exploded\\n");
114
- process.exit(4);
115
- };
116
- const barrierDirectory = process.env.RELAY_FAKE_PI_BARRIER_DIR;
117
- if (barrierDirectory === undefined) { finish(); return; }
118
- fs.mkdirSync(barrierDirectory, { recursive: true });
119
- fs.writeFileSync(path.join(barrierDirectory, String(process.pid)), "started");
120
- const expected = Number(process.env.RELAY_FAKE_PI_BARRIER_COUNT || "4");
121
- const deadline = Date.now() + 1_000;
122
- const releaseFile = process.env.RELAY_FAKE_PI_RELEASE_FILE;
123
- const waitForGroup = () => {
124
- if (fs.readdirSync(barrierDirectory).length < expected) {
125
- if (Date.now() >= deadline) { process.stderr.write("reviewer barrier timed out\\n"); process.exit(5); return; }
126
- setTimeout(waitForGroup, 10);
127
- return;
128
- }
129
- if (releaseFile !== undefined && !fs.existsSync(releaseFile)) { setTimeout(waitForGroup, 10); return; }
130
- finish();
131
- };
132
- waitForGroup();
133
- });
134
- `;
135
-
136
109
  interface RelayHarness {
137
110
  directory: string;
138
111
  gentleAi: string;
139
- pi: string;
140
112
  logPath: string;
141
- piLogPath: string;
142
- stdinCapturePath: string;
143
113
  submitCapturePath: string;
144
114
  targetCwd: string;
145
115
  environment: NodeJS.ProcessEnv;
@@ -149,32 +119,25 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
149
119
  const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
150
120
  t.after(() => rmSync(directory, { recursive: true, force: true }));
151
121
  const gentleAi = join(directory, "gentle-ai");
152
- const pi = join(directory, "pi");
153
122
  writeFileSync(gentleAi, FAKE_GENTLE_AI);
154
123
  chmodSync(gentleAi, 0o755);
155
- writeFileSync(pi, FAKE_PI);
156
- chmodSync(pi, 0o755);
157
124
  const logPath = join(directory, "gentle-ai.log");
158
- const piLogPath = join(directory, "pi.log");
159
- const stdinCapturePath = join(directory, "pi-stdin.bin");
160
125
  const submitCapturePath = join(directory, "submitted.bin");
161
126
  const targetCwd = join(directory, "target-worktree");
162
127
  mkdirSync(targetCwd);
163
128
  const environment: NodeJS.ProcessEnv = {
164
129
  ...process.env,
165
130
  RELAY_FAKE_LOG: logPath,
166
- RELAY_FAKE_PI_LOG: piLogPath,
167
- RELAY_FAKE_PI_STDIN_CAPTURE: stdinCapturePath,
168
131
  RELAY_FAKE_SUBMIT_CAPTURE: submitCapturePath,
169
132
  ...overrides,
170
133
  };
171
134
  // The relay itself must add the handshake; the base environment never
172
135
  // carries it, so the fake-binary log proves the injection.
173
136
  delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
174
- return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
137
+ return { directory, gentleAi, logPath, submitCapturePath, targetCwd, environment };
175
138
  }
176
139
 
177
- function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
140
+ function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string }> {
178
141
  if (!existsSync(path)) return [];
179
142
  return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
180
143
  }
@@ -207,34 +170,61 @@ const SUBMISSION: ReviewCaptureSubmissionV1 = Object.freeze({
207
170
  values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
208
171
  });
209
172
 
210
- // Prompt and result bytes deliberately include binary-unsafe content: NUL,
211
- // control bytes, quotes, backslashes, CRLF, multi-byte UTF-8, and bytes that
212
- // are not valid UTF-8 at all. The relay must move them verbatim.
173
+ // Prompt bytes deliberately include binary-unsafe content: NUL, control
174
+ // bytes, quotes, backslashes, CRLF, multi-byte UTF-8, and bytes that are not
175
+ // valid UTF-8 at all. The relay must materialize them verbatim into the
176
+ // completion request.
213
177
  const PROMPT_BYTES = Buffer.concat([
214
178
  Buffer.from('GENTLE_AI_REVIEW_BINDING {"lineage":"review-1d5aadacc600e167"}\n"quotes" \\backslash\r\n\u00e9\u{1F3A9}\n', "utf8"),
215
179
  Buffer.from([0x00, 0x01, 0x07, 0xff, 0xfe, 0x00]),
216
180
  ]);
217
- const PI_OUTPUT_BYTES = Buffer.concat([
218
- Buffer.from(`{"subject_hash":"sha256:${"a".repeat(64)}","findings":[]}\n`, "utf8"),
219
- Buffer.from([0x00, 0xf0, 0x9f, 0x8e, 0xa9, 0xff, 0x0d, 0x0a]),
220
- ]);
181
+ // The reviewer's completion text — what used to be the pi event stream's
182
+ // extracted assistant text is now simply the in-process outcome's `text`.
183
+ const REVIEWER_TEXT = `{"subject_hash":"sha256:${"a".repeat(64)}","findings":[]}\n🎉\r\n`;
221
184
 
222
- function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}) {
185
+ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}): ReviewHostRelayRequest {
223
186
  return {
224
187
  captureArgumentTokens: CAPTURE_TOKENS,
225
188
  submission: SUBMISSION,
226
189
  gentleAiExecutable: fixture.gentleAi,
227
- piExecutable: fixture.pi,
228
190
  targetCwd: fixture.targetCwd,
191
+ reviewerRegistry: {
192
+ find: () => ({ id: "gpt-5", name: "GPT-5", api: "openai-responses", provider: "openai", baseUrl: "https://example.invalid", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100_000, maxTokens: 8192 }) as never,
193
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key" }),
194
+ },
195
+ selection: "openai/gpt-5",
196
+ routingKey: "review-reliability",
229
197
  environment: {
230
198
  ...fixture.environment,
231
199
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
232
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
233
200
  },
234
201
  gentleAiTimeoutMs: 30_000,
235
202
  piTimeoutMs: 30_000,
236
203
  ...overrides,
237
- };
204
+ } as ReviewHostRelayRequest;
205
+ }
206
+
207
+ /** A `runReviewer` fake resolving with canned completion text, capturing every call. */
208
+ function textReviewer(text: string, reviewerModel = "openai/gpt-5") {
209
+ const calls: InProcessReviewerRequest[] = [];
210
+ const runReviewer = (async (request: InProcessReviewerRequest, _deps: InProcessReviewerDeps): Promise<InProcessReviewerOutcome> => {
211
+ calls.push(request);
212
+ return { kind: "text", text, reviewerModel };
213
+ }) as typeof runInProcessReviewer;
214
+ return { runReviewer, calls };
215
+ }
216
+
217
+ /** A `runReviewer` fake resolving with a canned typed refusal. */
218
+ function refusingReviewer(code: (typeof INPROCESS_REVIEWER_FAILURE)[keyof typeof INPROCESS_REVIEWER_FAILURE], message: string, evidence?: Record<string, unknown>, delayMs = 0): typeof runInProcessReviewer {
219
+ return (async () => {
220
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
221
+ return { kind: "refused", code, message, ...(evidence === undefined ? {} : { evidence }) };
222
+ }) as typeof runInProcessReviewer;
223
+ }
224
+
225
+ /** Runs one relay slot with a safe-by-default fake reviewer; no test may reach the real network unless it explicitly overrides this. */
226
+ function runRelay(fixture: RelayHarness, overrides: Record<string, unknown> = {}, runReviewer: typeof runInProcessReviewer = textReviewer(REVIEWER_TEXT).runReviewer): Promise<{ promptByteLength: number; resultByteLength: number; submission: string }> {
227
+ return runReviewHostRelaySlot(relayRequest(fixture, overrides), runReviewer);
238
228
  }
239
229
 
240
230
  function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[] {
@@ -242,15 +232,14 @@ function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[]
242
232
  captureArgumentTokens: CAPTURE_TOKENS.map((token) => token === "--lens=review-reliability"
243
233
  ? `--lens=${lens}`
244
234
  : token === "--order=0" ? `--order=${order}` : token),
235
+ routingKey: lens,
245
236
  environment: {
246
237
  ...fixture.environment,
247
238
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
248
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
249
239
  },
250
240
  }));
251
241
  }
252
242
 
253
-
254
243
  async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
255
244
  let caught: ReviewHostRelayError | undefined;
256
245
  await assert.rejects(promise, (error: unknown) => {
@@ -289,28 +278,36 @@ test("relay contract constants are the compiled gentle-ai handshake values", ()
289
278
  assert.equal(GENTLE_PI_REVIEW_RELAY_CONTRACT, "gentle-pi.review-relay/v1");
290
279
  });
291
280
 
281
+ test("no pi child launcher is exported: the lens path completes in-process", () => {
282
+ const relayExports = reviewHostRelayModule as unknown as Record<string, unknown>;
283
+ assert.equal("REVIEW_HOST_RELAY_PI_ARGV" in relayExports, false, "the pinned pi argv export must be gone");
284
+ assert.equal("resolveReviewHostRelayExtensionPaths" in relayExports, false, "the extension allowlist reader must be gone");
285
+ });
286
+
292
287
  // ---------------------------------------------------------------------------
293
288
  // Happy path
294
289
  // ---------------------------------------------------------------------------
295
290
 
296
- test("relay happy path moves prompt and result bytes verbatim through a fresh empty scratch pi subprocess", async (t) => {
291
+ test("relay happy path moves prompt bytes verbatim into the completion and submits its text through the exact provider submission token", async (t) => {
297
292
  const fixture = harness(t);
298
- const result = await runReviewHostRelaySlot(relayRequest(fixture));
293
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
294
+ const result = await runRelay(fixture, {}, runReviewer);
299
295
 
300
296
  assert.equal(result.promptByteLength, PROMPT_BYTES.length);
301
- assert.equal(result.resultByteLength, PI_OUTPUT_BYTES.length);
297
+ assert.equal(result.resultByteLength, Buffer.byteLength(REVIEWER_TEXT));
302
298
  assert.equal(JSON.parse(result.submission).admission_decision, "completed");
303
299
 
304
- // Prompt bytes reached pi stdin verbatim.
305
- assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
306
- // Submission --input file bytes are EXACTLY the pi stdout bytes.
307
- assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
300
+ // Prompt bytes reached the completion request verbatim.
301
+ assert.equal(calls.length, 1);
302
+ assert.ok(calls[0]!.prompt.equals(PROMPT_BYTES));
303
+ // Submission --input file bytes are EXACTLY the reviewer's completion text.
304
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(REVIEWER_TEXT, "utf8"));
308
305
 
309
306
  const gentleAiCalls = readLog(fixture.logPath);
310
307
  assert.equal(gentleAiCalls.length, 2);
311
308
  // (a) exact provider tokens, verbatim, in provider order.
312
309
  assert.deepEqual(gentleAiCalls[0]!.argv, ["review", "capture-result", ...CAPTURE_TOKENS]);
313
- // (d) the provider-owned submission form, verbatim: its exact operation
310
+ // (b) the provider-owned submission form, verbatim: its exact operation
314
311
  // and argument tokens with only the artifact path substituted into the
315
312
  // declared {{value}} slot. No agent/materialize, nothing synthesized.
316
313
  assert.deepEqual(gentleAiCalls[1]!.argv.slice(0, 2 + BINDING_TOKENS.length), ["review", SUBMISSION.operationToken, ...BINDING_TOKENS]);
@@ -324,81 +321,74 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
324
321
  // environment carried none.
325
322
  assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
326
323
  assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
327
-
328
- const piCalls = readLog(fixture.piLogPath);
329
- assert.equal(piCalls.length, 1);
330
- // The pi environment stays untouched: no relay handshake is injected.
331
- assert.equal(piCalls[0]!.contract, null);
332
- // Fresh EMPTY scratch cwd, removed after the run.
333
- assert.deepEqual(piCalls[0]!.entries, []);
334
- assert.notEqual(piCalls[0]!.cwd, process.cwd());
335
- assert.equal(existsSync(piCalls[0]!.cwd!), false);
336
324
  });
337
325
 
338
- test("the pi lockdown argv is pinned exactly with no model or provider selection", async (t) => {
326
+ // gentle-pi#311 P3: a v9 provider role slot (refuter, targeted validator)
327
+ // reaches this same relay through the exact same request shape a lens
328
+ // materialize slot uses — the only difference is the operation name the
329
+ // provider's own submission descriptor names. The materialize invocation
330
+ // must follow that name, never a hardcoded "capture-result".
331
+ test("the materialize invocation follows the provider's own submission operation, not a hardcoded capture-result", async (t) => {
339
332
  const fixture = harness(t);
340
- await runReviewHostRelaySlot(relayRequest(fixture));
341
- const piCalls = readLog(fixture.piLogPath);
342
- const expected = [
343
- "--print",
344
- "--mode", "text",
345
- "--no-session",
346
- "--no-tools",
347
- "--no-extensions",
348
- "--no-skills",
349
- "--no-prompt-templates",
350
- "--no-themes",
351
- "--no-context-files",
352
- "--no-approve",
333
+ const bindingTokens = [
334
+ "--lineage=review-1d5aadacc600e167",
335
+ `--expected-revision=sha256:${"c".repeat(64)}`,
336
+ `--target=sha256:${"d".repeat(64)}`,
337
+ `--repository-context=rctx1_${"e".repeat(64)}`,
353
338
  ];
354
- assert.deepEqual([...REVIEW_HOST_RELAY_PI_ARGV], expected);
355
- assert.deepEqual(piCalls[0]!.argv, expected);
356
- assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
339
+ const refuterCaptureTokens = [...bindingTokens, "--agent=pi", "--materialize=true"];
340
+ const refuterSubmission: ReviewCaptureSubmissionV1 = {
341
+ operationToken: "capture-refuter",
342
+ argumentTokens: [...bindingTokens, "--agent=pi", "--input={{value}}"],
343
+ values: [{ slot: "provider_refuter", domain: "artifact_path_or_stdin", substitutionLocation: bindingTokens.length + 1 }],
344
+ };
345
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
346
+ const result = await runRelay(fixture, { captureArgumentTokens: refuterCaptureTokens, submission: refuterSubmission, routingKey: "review-refuter" }, runReviewer);
347
+
348
+ assert.equal(JSON.parse(result.submission).admission_decision, "completed");
349
+ assert.equal(calls.length, 1);
350
+ const gentleAiCalls = readLog(fixture.logPath);
351
+ assert.equal(gentleAiCalls.length, 2);
352
+ assert.deepEqual(gentleAiCalls[0]!.argv, ["review", "capture-refuter", ...refuterCaptureTokens]);
353
+ assert.deepEqual(gentleAiCalls[1]!.argv.slice(0, 2 + bindingTokens.length), ["review", "capture-refuter", ...bindingTokens]);
354
+ assert.equal(gentleAiCalls[1]!.argv[2 + bindingTokens.length], "--agent=pi");
355
+ const submitted = gentleAiCalls[1]!.argv.at(-1)!;
356
+ assert.match(submitted, /^--input=\S+$/);
357
357
  });
358
358
 
359
359
  test("preparation snapshots mutable submission tokens and values before materialization", async (t) => {
360
360
  const fixture = harness(t);
361
- const barrierDirectory = join(fixture.directory, "submission-snapshot-barrier");
362
- const releaseFile = join(fixture.directory, "submission-snapshot-release");
363
361
  const submissionTokens = [...SUBMISSION.argumentTokens];
364
362
  const submissionValues = SUBMISSION.values.map((value) => ({ ...value }));
365
363
  const submission: ReviewCaptureSubmissionV1 = { ...SUBMISSION, argumentTokens: submissionTokens, values: submissionValues };
366
- const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, {
367
- submission,
368
- environment: {
369
- ...fixture.environment,
370
- RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
371
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
372
- RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
373
- RELAY_FAKE_PI_BARRIER_COUNT: "1",
374
- RELAY_FAKE_PI_RELEASE_FILE: releaseFile,
375
- },
376
- }));
377
- await waitFor(() => readLog(fixture.piLogPath).length === 1, "reviewer did not reach the snapshot barrier");
364
+ let releaseCompletion: (() => void) | undefined;
365
+ const gate = new Promise<void>((resolve) => { releaseCompletion = resolve; });
366
+ let reached = false;
367
+ const runReviewer = (async () => {
368
+ reached = true;
369
+ await gate;
370
+ return { kind: "text", text: REVIEWER_TEXT, reviewerModel: "openai/gpt-5" } as InProcessReviewerOutcome;
371
+ }) as typeof runInProcessReviewer;
372
+ const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, { submission }), runReviewer);
373
+ await waitFor(() => reached, "the completion was never reached");
378
374
  submissionTokens[submissionTokens.length - 1] = "--input=mutated";
379
375
  submissionValues[0]!.slot = "mutated";
380
- writeFileSync(releaseFile, "release");
376
+ releaseCompletion!();
381
377
 
382
378
  const result = await prepared;
383
379
  assert.deepEqual(result.request.submission, SUBMISSION);
384
380
  await submitReviewHostRelayPreparedResult(result);
385
- assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
381
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(REVIEWER_TEXT, "utf8"));
386
382
  });
387
383
 
388
384
  test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
389
385
  const fixture = harness(t);
390
- const reviewerBytes = Buffer.from(PI_OUTPUT_BYTES);
391
- const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), async () => ({
392
- stdout: reviewerBytes,
393
- promptByteLength: PROMPT_BYTES.length,
394
- stdoutByteLength: reviewerBytes.length,
395
- }));
396
- reviewerBytes.fill(0);
386
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), textReviewer(REVIEWER_TEXT).runReviewer);
397
387
  const mutablePrepared = prepared as unknown as { resultBytes?: Buffer };
398
388
  mutablePrepared.resultBytes?.fill(0);
399
389
  assert.throws(() => { mutablePrepared.resultBytes = Buffer.from("fabricated"); }, TypeError);
400
390
  await submitReviewHostRelayPreparedResult(prepared);
401
- assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
391
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(REVIEWER_TEXT, "utf8"));
402
392
 
403
393
  const fabricated = { ...prepared, resultBytes: Buffer.from("fabricated") } as unknown as ReviewHostRelayPreparedResult;
404
394
  await assert.rejects(submitReviewHostRelayPreparedResult(fabricated), /recognized prepared result/);
@@ -408,7 +398,7 @@ test("preparation keeps reviewer bytes private through deferred submission", asy
408
398
  test("the supplied AbortSignal stays live through deferred submission", async (t) => {
409
399
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_DELAY_MS: "1000" });
410
400
  const controller = new AbortController();
411
- const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }));
401
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }), textReviewer(REVIEWER_TEXT).runReviewer);
412
402
  const submission = submitReviewHostRelayPreparedResult(prepared);
413
403
  await waitFor(() => readLog(fixture.logPath).length === 2, "submission did not start");
414
404
  controller.abort();
@@ -417,21 +407,22 @@ test("the supplied AbortSignal stays live through deferred submission", async (t
417
407
 
418
408
  test("four reviewers cross a shared barrier before any result submission can begin", async (t) => {
419
409
  const fixture = harness(t);
420
- const barrierDirectory = join(fixture.directory, "reviewer-barrier");
421
- const requests = reviewerGroupRequests(fixture).map((request) => ({
422
- ...request,
423
- environment: {
424
- ...request.environment,
425
- RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
426
- RELAY_FAKE_PI_BARRIER_COUNT: String(REVIEWER_GROUP_LENSES.length),
427
- },
428
- }));
429
-
430
- const prepared = await runReviewHostRelayReviewerGroup(requests);
410
+ const requests = reviewerGroupRequests(fixture);
411
+ let started = 0;
412
+ const releases: Array<() => void> = [];
413
+ const runReviewer = (async () => {
414
+ started += 1;
415
+ await new Promise<void>((resolve) => releases.push(resolve));
416
+ return { kind: "text", text: REVIEWER_TEXT, reviewerModel: "openai/gpt-5" } as InProcessReviewerOutcome;
417
+ }) as typeof runInProcessReviewer;
418
+
419
+ const group = runReviewHostRelayReviewerGroup(requests, (request) => prepareReviewHostRelaySlot(request, runReviewer));
420
+ await waitFor(() => started === REVIEWER_GROUP_LENSES.length, "every reviewer must start before any completes");
421
+ for (const release of releases) release();
422
+ const prepared = await group;
431
423
 
432
424
  assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
433
- assert.ok(prepared.every((result) => result.resultByteLength === PI_OUTPUT_BYTES.length));
434
- assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
425
+ assert.ok(prepared.every((result) => result.resultByteLength === Buffer.byteLength(REVIEWER_TEXT)));
435
426
  assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
436
427
  });
437
428
 
@@ -499,50 +490,112 @@ test("partial reviewer failures wait for a later pending transport and report th
499
490
  // Fail-closed legs — a typed transport error and NO submission.
500
491
  // ---------------------------------------------------------------------------
501
492
 
502
- test("materialize nonzero exit fails closed with a typed error and never launches pi or submits", async (t) => {
493
+ test("materialize nonzero exit fails closed with a typed error and never runs the completion or submits", async (t) => {
503
494
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "fail" });
504
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize");
495
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
496
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize");
505
497
  assert.equal(error.exitCode, 3);
506
498
  assert.equal(error.mutationOutcome, "none");
507
499
  assert.equal(readLog(fixture.logPath).length, 1);
508
- assert.equal(readLog(fixture.piLogPath).length, 0);
500
+ assert.equal(calls.length, 0, "no completion may run after a materialize failure");
509
501
  assert.equal(existsSync(fixture.submitCapturePath), false);
510
502
  });
511
503
 
512
- test("an empty materialized prompt fails closed before pi launches", async (t) => {
504
+ test("an empty materialized prompt fails closed before the completion runs", async (t) => {
513
505
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "empty" });
514
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize");
515
- assert.equal(readLog(fixture.piLogPath).length, 0);
506
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
507
+ await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize");
508
+ assert.equal(calls.length, 0);
516
509
  assert.equal(existsSync(fixture.submitCapturePath), false);
517
510
  });
518
511
 
519
- test("pi nonzero exit fails closed with a typed error and no submission", async (t) => {
520
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
521
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
522
- assert.equal(error.exitCode, 4);
523
- assert.equal(error.mutationOutcome, "none");
524
- assert.equal(readLog(fixture.logPath).length, 1, "no submission invocation after pi failure");
525
- assert.equal(existsSync(fixture.submitCapturePath), false);
512
+ // ---------------------------------------------------------------------------
513
+ // Every in-process reviewer refusal code maps to its typed relay code.
514
+ // ---------------------------------------------------------------------------
515
+
516
+ test("every in-process reviewer refusal maps to its typed relay failure code, carrying evidence when the outcome had any", async (t) => {
517
+ const cases: Array<{ code: (typeof INPROCESS_REVIEWER_FAILURE)[keyof typeof INPROCESS_REVIEWER_FAILURE]; kind: string; evidence?: Record<string, unknown> }> = [
518
+ { code: INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID },
519
+ { code: INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_MODEL_NOT_FOUND },
520
+ { code: INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE },
521
+ { code: INPROCESS_REVIEWER_FAILURE.THINKING_INVALID, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID },
522
+ { code: INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL },
523
+ { code: INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT, evidence: { stopReason: "stop" } },
524
+ { code: INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE, kind: REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE },
525
+ { code: INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, kind: REVIEW_HOST_RELAY_FAILURE.PI_FAILED },
526
+ ];
527
+ for (const { code, kind, evidence } of cases) {
528
+ const fixture = harness(t);
529
+ const message = `refused: ${code}`;
530
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, refusingReviewer(code, message, evidence)), kind, "pi");
531
+ assert.equal(error.message, message);
532
+ assert.equal(error.mutationOutcome, "none");
533
+ assert.equal(existsSync(fixture.submitCapturePath), false, `${code} must never reach submission`);
534
+ if (evidence !== undefined) assert.deepEqual(error.reviewerEvidence, evidence, `${code} must carry its outcome evidence`);
535
+ }
526
536
  });
527
537
 
528
- test("empty pi stdout fails closed with a typed error and no submission", async (t) => {
529
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "empty" });
530
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
531
- assert.equal(readLog(fixture.logPath).length, 1);
538
+ test("a reviewer run that only attempted a tool call fails typed and never submits", async (t) => {
539
+ const fixture = harness(t);
540
+ const error = await rejectsWithRelayError(
541
+ runRelay(fixture, {}, refusingReviewer(INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED, "Reviewer attempted a tool call for review-reliability; the in-process reviewer completion must answer in text only.")),
542
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL,
543
+ "pi",
544
+ );
532
545
  assert.equal(existsSync(fixture.submitCapturePath), false);
546
+ assert.match(error.message, /tool call/);
533
547
  });
534
548
 
535
- test("pi timeout fails closed with a typed error and no submission", async (t) => {
536
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
537
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
538
- assert.equal(error.timedOut, true);
539
- assert.equal(readLog(fixture.logPath).length, 1);
549
+ test("the relay forwards the caller-owned selection and thinking level to the completion request", async (t) => {
550
+ const fixture = harness(t);
551
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
552
+ await runRelay(fixture, { selection: "minimax/MiniMax-M3", thinking: "high", routingKey: "review-risk" }, runReviewer);
553
+ assert.equal(calls.length, 1);
554
+ assert.equal(calls[0]!.selection, "minimax/MiniMax-M3");
555
+ assert.equal(calls[0]!.thinking, "high");
556
+ assert.equal(calls[0]!.routingKey, "review-risk");
557
+ });
558
+
559
+ test("a missing model registry is refused typed before materialize ever runs", async (t) => {
560
+ const fixture = harness(t);
561
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
562
+ const error = await rejectsWithRelayError(runRelay(fixture, { reviewerRegistry: undefined }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi");
563
+ assert.equal(calls.length, 0, "no completion may run on a broken configuration");
564
+ assert.equal(readLog(fixture.logPath).length, 0, "no materialization may run without a model registry");
565
+ assert.match(error.message, /model registry/);
566
+ });
567
+
568
+ test("a routing entry with no configured model is refused typed, naming the routing key, before materialize ever runs", async (t) => {
569
+ const fixture = harness(t);
570
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
571
+ const error = await rejectsWithRelayError(runRelay(fixture, { selection: undefined, routingKey: "review-risk" }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi");
572
+ assert.equal(calls.length, 0);
573
+ assert.equal(readLog(fixture.logPath).length, 0, "no materialization may run without a configured model");
574
+ assert.match(error.message, /review-risk/);
575
+ });
576
+
577
+ test("a tool call's cancelled signal maps to REVIEWER_ABORTED and forwards the caller's own signal to the completion", async (t) => {
578
+ const fixture = harness(t);
579
+ const controller = new AbortController();
580
+ let receivedSignal: AbortSignal | undefined;
581
+ // The signal aborts once the completion actually starts (materialize must
582
+ // already have succeeded with a live signal); this proves the same signal
583
+ // object reaches the completion request, not just that an early abort
584
+ // short-circuits materialize too.
585
+ const runReviewer = (async (request: InProcessReviewerRequest) => {
586
+ receivedSignal = request.signal;
587
+ controller.abort();
588
+ return { kind: "refused", code: INPROCESS_REVIEWER_FAILURE.ABORTED, message: "Reviewer completion for review-reliability was aborted by the caller." } as InProcessReviewerOutcome;
589
+ }) as typeof runInProcessReviewer;
590
+ const error = await rejectsWithRelayError(runRelay(fixture, { signal: controller.signal }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED, "pi");
591
+ assert.equal(receivedSignal, controller.signal, "the relay must forward the tool call's own signal into the completion request");
592
+ assert.match(error.message, /aborted/);
540
593
  assert.equal(existsSync(fixture.submitCapturePath), false);
541
594
  });
542
595
 
543
596
  // ---------------------------------------------------------------------------
544
597
  // gentle-pi#367 — the reviewer bound is reachable from production, and a
545
- // reviewer killed by it says so with both measurements.
598
+ // timed-out completion says so with both measurements.
546
599
  // ---------------------------------------------------------------------------
547
600
 
548
601
  test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
@@ -576,50 +629,26 @@ test("the reviewer bound honours the environment override and ignores malformed
576
629
  }
577
630
  });
578
631
 
579
- test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
580
- // The regression: piTimeoutMs was reachable only through the test seam, so
581
- // production always ran against the fixed 600s bound. This request injects
582
- // no timeout at all — the override must reach the real spawn.
583
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
632
+ test("the production relay path resolves the reviewer bound from the environment and forwards it to the completion, with no injected timeout", async (t) => {
633
+ // The regression this guards: piTimeoutMs was reachable only through the
634
+ // test seam, so production always ran against a fixed bound. This request
635
+ // injects no timeout at all — the override must reach the completion.
636
+ const fixture = harness(t, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300000" });
637
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
638
+ await runRelay(fixture, { piTimeoutMs: undefined }, runReviewer);
639
+ assert.equal(calls[0]!.timeoutMs, 300_000);
640
+ });
641
+
642
+ test("a timed-out reviewer completion reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
643
+ const fixture = harness(t);
584
644
  const error = await rejectsWithRelayError(
585
- runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
645
+ runRelay(fixture, { piTimeoutMs: 300 }, refusingReviewer(INPROCESS_REVIEWER_FAILURE.TIMED_OUT, "Reviewer completion for review-reliability exceeded its 300ms bound.", undefined, 20)),
586
646
  REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
587
647
  "pi",
588
648
  );
589
649
  assert.equal(error.timeoutMs, 300);
650
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 15, `elapsed ${error.elapsedMs} must record the real wall time`);
590
651
  assert.equal(error.timedOut, true);
591
- assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
592
- assert.equal(existsSync(fixture.submitCapturePath), false);
593
- });
594
-
595
- test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
596
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
597
- const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
598
- const originalTmpdir = process.env.TMPDIR;
599
- process.env.TMPDIR = scratchParent;
600
- try {
601
- const error = await rejectsWithRelayError(
602
- runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
603
- REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
604
- "pi",
605
- );
606
- assert.equal(error.timedOut, true);
607
- assert.equal(error.timeoutMs, 300);
608
- assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
609
- } finally {
610
- if (originalTmpdir === undefined) delete process.env.TMPDIR;
611
- else process.env.TMPDIR = originalTmpdir;
612
- chmodSync(scratchParent, 0o700);
613
- rmSync(scratchParent, { recursive: true, force: true });
614
- }
615
- });
616
-
617
- test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
618
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
619
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
620
- assert.equal(error.timeoutMs, 300);
621
- assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
622
- assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
623
652
  assert.match(error.message, /exceeded the relay bound/);
624
653
  assert.match(error.message, new RegExp(String(error.elapsedMs)));
625
654
  assert.match(error.message, /limit for a \d+-byte materialized prompt/);
@@ -627,18 +656,21 @@ test("a killed reviewer reports elapsed, limit, and what to change instead of an
627
656
  assert.equal(error.mutationOutcome, "none");
628
657
  });
629
658
 
630
- test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
631
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
632
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
659
+ test("a provider-failed completion stays distinguishable from a timed-out one and still carries its measurements", async (t) => {
660
+ const fixture = harness(t);
661
+ const error = await rejectsWithRelayError(
662
+ runRelay(fixture, { piTimeoutMs: 30_000 }, refusingReviewer(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, "Reviewer completion failed for review-reliability: upstream 500")),
663
+ REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
664
+ "pi",
665
+ );
633
666
  assert.equal(error.timedOut, false);
634
- assert.equal(error.exitCode, 4);
635
667
  assert.equal(error.timeoutMs, 30_000);
636
668
  assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
637
669
  });
638
670
 
639
671
  test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
640
672
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
641
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
673
+ const error = await rejectsWithRelayError(runRelay(fixture), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
642
674
  assert.equal(error.mutationOutcome, "unknown");
643
675
  assert.match(error.stderr, /capture binding does not match/);
644
676
  assert.equal(readLog(fixture.logPath).length, 2);
@@ -648,20 +680,14 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
648
680
  // typed [invalid_request] refusal and states that the lens slot was not
649
681
  // consumed. That is a proven non-mutation, not an unknown outcome, and the
650
682
  // refusal text is the only thing that tells the host what to change.
651
- function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
652
- return relayRequest(fixture, {
653
- environment: {
654
- ...fixture.environment,
655
- RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
656
- RELAY_FAKE_PI_OUTPUT_B64: reviewerOutput.toString("base64"),
657
- },
658
- });
683
+ function admittingRequest(fixture: RelayHarness): ReviewHostRelayRequest {
684
+ return relayRequest(fixture);
659
685
  }
660
686
 
661
687
  test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
662
688
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
663
689
  const error = await rejectsWithRelayError(
664
- runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
690
+ runReviewHostRelaySlot(admittingRequest(fixture), textReviewer("not json at all").runReviewer),
665
691
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
666
692
  "submit",
667
693
  );
@@ -676,9 +702,9 @@ test("a reviewer printing garbage is refused at admission as a proven non-mutati
676
702
 
677
703
  test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
678
704
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
679
- const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
705
+ const wrongSubject = JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] });
680
706
  const error = await rejectsWithRelayError(
681
- runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
707
+ runReviewHostRelaySlot(admittingRequest(fixture), textReviewer(wrongSubject).runReviewer),
682
708
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
683
709
  "submit",
684
710
  );
@@ -693,7 +719,8 @@ test("a reviewer echoing a different subject is refused at admission as a proven
693
719
 
694
720
  test("a submission the fake admits with the expected subject still completes", async (t) => {
695
721
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
696
- const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
722
+ const expected = JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] });
723
+ const result = await runReviewHostRelaySlot(admittingRequest(fixture), textReviewer(expected).runReviewer);
697
724
  assert.equal(JSON.parse(result.submission).admission_decision, "completed");
698
725
  });
699
726
 
@@ -704,7 +731,7 @@ test("submission refusal preserves its primary evidence when result staging clea
704
731
  process.env.TMPDIR = scratchParent;
705
732
  try {
706
733
  const error = await rejectsWithRelayError(
707
- runReviewHostRelaySlot(relayRequest(fixture)),
734
+ runRelay(fixture),
708
735
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
709
736
  "submit",
710
737
  );
@@ -722,12 +749,10 @@ test("submission refusal preserves its primary evidence when result staging clea
722
749
  }
723
750
  });
724
751
 
725
- test("relay scratch and staging directories are removed after failures too", async (t) => {
726
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
727
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
728
- const piCalls = readLog(fixture.piLogPath);
729
- assert.equal(piCalls.length, 1);
730
- assert.equal(existsSync(piCalls[0]!.cwd!), false);
752
+ test("relay staging directories are removed after a submission failure too", async (t) => {
753
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
754
+ await rejectsWithRelayError(runRelay(fixture), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
755
+ assert.equal(readLog(fixture.logPath).length, 2);
731
756
  });
732
757
 
733
758
  test("a result staging cleanup failure remains a typed submit failure", async (t) => {
@@ -737,7 +762,7 @@ test("a result staging cleanup failure remains a typed submit failure", async (t
737
762
  process.env.TMPDIR = scratchParent;
738
763
  try {
739
764
  const error = await rejectsWithRelayError(
740
- runReviewHostRelaySlot(relayRequest(fixture)),
765
+ runRelay(fixture),
741
766
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
742
767
  "submit",
743
768
  );
@@ -757,20 +782,22 @@ test("a result staging cleanup failure remains a typed submit failure", async (t
757
782
 
758
783
  test("an old binary's unknown-flag refusal classifies as relay-unavailable with the exact report", async (t) => {
759
784
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "unknown-flag" });
760
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize");
785
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
786
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize");
761
787
  assert.equal(error.message, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE);
762
788
  assert.match(error.stderr, /flag provided but not defined: -materialize/);
763
- assert.equal(readLog(fixture.piLogPath).length, 0);
789
+ assert.equal(calls.length, 0);
764
790
  assert.equal(existsSync(fixture.submitCapturePath), false);
765
791
  });
766
792
 
767
793
  test("a handshake refusal surfaces the provider refusal verbatim", async (t) => {
768
794
  const refusal = "review capture-result --agent pi: the active runtime is not eligible for immutable receipt review; supported immutable review runtimes: claude-code, codex, opencode";
769
795
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "handshake", RELAY_FAKE_HANDSHAKE_STDERR: refusal });
770
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize");
796
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
797
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize");
771
798
  assert.equal(error.message, refusal);
772
799
  assert.equal(error.stderr, refusal);
773
- assert.equal(readLog(fixture.piLogPath).length, 0);
800
+ assert.equal(calls.length, 0);
774
801
  });
775
802
 
776
803
  test("refusal classification distinguishes unknown-flag, handshake, and other", () => {
@@ -785,7 +812,7 @@ test("refusal classification distinguishes unknown-flag, handshake, and other",
785
812
  // deterministic for this exact slot. Generic admission refusals are repairable
786
813
  // by a fresh reviewer and retain the ordinary exact-reoffer behavior.
787
814
  test("the unachievable predicate names only deterministic slot failures", () => {
788
- const killed = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
815
+ const killed = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "the reviewer completion exceeded the relay bound", { timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
789
816
  assert.equal(reviewHostRelayUnachievableReason(killed), "relay_transport_bound_exceeded");
790
817
  assert.equal(reviewHostRelayUnachievableDetail(killed), "killed after 2256004ms against a 2256000ms relay bound");
791
818
 
@@ -799,7 +826,24 @@ test("the unachievable predicate names only deterministic slot failures", () =>
799
826
  assert.equal(reviewHostRelayUnachievableReason(unknownOutcome), undefined, "an unknown mutation outcome is never deterministic");
800
827
  assert.equal(reviewHostRelayUnachievableDetail(unknownOutcome), undefined);
801
828
 
802
- for (const kind of [REVIEW_HOST_RELAY_FAILURE.PI_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH]) {
829
+ for (const kind of [
830
+ REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
831
+ REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
832
+ REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT,
833
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_MODEL_NOT_FOUND,
834
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE,
835
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID,
836
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL,
837
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT,
838
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE,
839
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED,
840
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
841
+ REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED,
842
+ REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT,
843
+ REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE,
844
+ REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED,
845
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
846
+ ]) {
803
847
  assert.equal(reviewHostRelayUnachievableReason(new ReviewHostRelayError(kind, "pi", "transient")), undefined, `${kind} stays transient`);
804
848
  }
805
849
 
@@ -873,37 +917,39 @@ test("relay input validation rejects empty or malformed inputs before any proces
873
917
 
874
918
  test("a materialize slot without a provider submission fails closed before any process launches", async (t) => {
875
919
  const fixture = harness(t);
920
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
876
921
  const error = await rejectsWithRelayError(
877
- runReviewHostRelaySlot(relayRequest(fixture, { submission: undefined })),
922
+ runRelay(fixture, { submission: undefined }, runReviewer),
878
923
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
879
924
  "binding",
880
925
  );
881
926
  assert.equal(error.message, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
882
927
  assert.equal(error.mutationOutcome, "none");
883
928
  assert.equal(readLog(fixture.logPath).length, 0, "no gentle-ai invocation was launched");
884
- assert.equal(readLog(fixture.piLogPath).length, 0, "no pi subprocess was launched");
929
+ assert.equal(calls.length, 0, "no completion was run");
885
930
  assert.equal(existsSync(fixture.submitCapturePath), false);
886
931
  });
887
932
 
888
933
  test("a submission form the relay cannot bind is a typed contract mismatch, never a repaired invocation", async (t) => {
889
934
  const fixture = harness(t);
935
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
890
936
  const twoValues: ReviewCaptureSubmissionV1 = {
891
937
  ...SUBMISSION,
892
938
  values: [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }],
893
939
  };
894
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: twoValues })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
940
+ await rejectsWithRelayError(runRelay(fixture, { submission: twoValues }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
895
941
  const noSlot: ReviewCaptureSubmissionV1 = {
896
942
  ...SUBMISSION,
897
943
  argumentTokens: [...BINDING_TOKENS, "--input=/etc/somewhere"],
898
944
  };
899
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: noSlot })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
945
+ await rejectsWithRelayError(runRelay(fixture, { submission: noSlot }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
900
946
  const outOfBounds: ReviewCaptureSubmissionV1 = {
901
947
  ...SUBMISSION,
902
948
  values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: SUBMISSION.argumentTokens.length }],
903
949
  };
904
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: outOfBounds })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
950
+ await rejectsWithRelayError(runRelay(fixture, { submission: outOfBounds }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
905
951
  assert.equal(readLog(fixture.logPath).length, 0);
906
- assert.equal(readLog(fixture.piLogPath).length, 0);
952
+ assert.equal(calls.length, 0);
907
953
  });
908
954
 
909
955
  test("resolveReviewHostRelaySubmission returns the provider binding untouched", () => {