gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -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,44 +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
- // The reviewer child runs `pi --mode json` and the transport submits the
218
- // assistant text of that event stream, so the fixture wraps the reviewer
219
- // payload in the minimal event shape the extraction understands.
220
- const PI_REVIEWER_TEXT = `{"subject_hash":"sha256:${"a".repeat(64)}","findings":[]}\n🎉\r\n`;
221
- const piEventStream = (...events: readonly unknown[]): Buffer => Buffer.from(events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8");
222
- const assistantEnd = (text: string, reviewerModel?: string) => {
223
- const message: Record<string, unknown> = { role: "assistant", content: [{ type: "text", text }] };
224
- if (reviewerModel !== undefined) message["model"] = reviewerModel;
225
- return { type: "message_end", message };
226
- };
227
- const PI_OUTPUT_BYTES = piEventStream(
228
- { type: "message_start", message: { role: "user", content: [] } },
229
- assistantEnd(PI_REVIEWER_TEXT),
230
- );
231
-
232
- function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}) {
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`;
184
+
185
+ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}): ReviewHostRelayRequest {
233
186
  return {
234
187
  captureArgumentTokens: CAPTURE_TOKENS,
235
188
  submission: SUBMISSION,
236
189
  gentleAiExecutable: fixture.gentleAi,
237
- piExecutable: fixture.pi,
238
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",
239
197
  environment: {
240
198
  ...fixture.environment,
241
199
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
242
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
243
200
  },
244
201
  gentleAiTimeoutMs: 30_000,
245
202
  piTimeoutMs: 30_000,
246
203
  ...overrides,
247
- };
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);
248
228
  }
249
229
 
250
230
  function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[] {
@@ -252,15 +232,14 @@ function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[]
252
232
  captureArgumentTokens: CAPTURE_TOKENS.map((token) => token === "--lens=review-reliability"
253
233
  ? `--lens=${lens}`
254
234
  : token === "--order=0" ? `--order=${order}` : token),
235
+ routingKey: lens,
255
236
  environment: {
256
237
  ...fixture.environment,
257
238
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
258
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
259
239
  },
260
240
  }));
261
241
  }
262
242
 
263
-
264
243
  async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
265
244
  let caught: ReviewHostRelayError | undefined;
266
245
  await assert.rejects(promise, (error: unknown) => {
@@ -299,28 +278,36 @@ test("relay contract constants are the compiled gentle-ai handshake values", ()
299
278
  assert.equal(GENTLE_PI_REVIEW_RELAY_CONTRACT, "gentle-pi.review-relay/v1");
300
279
  });
301
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
+
302
287
  // ---------------------------------------------------------------------------
303
288
  // Happy path
304
289
  // ---------------------------------------------------------------------------
305
290
 
306
- test("relay happy path moves prompt bytes verbatim and the pi event stream's assistant text 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) => {
307
292
  const fixture = harness(t);
308
- const result = await runReviewHostRelaySlot(relayRequest(fixture));
293
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
294
+ const result = await runRelay(fixture, {}, runReviewer);
309
295
 
310
296
  assert.equal(result.promptByteLength, PROMPT_BYTES.length);
311
- assert.equal(result.resultByteLength, Buffer.byteLength(PI_REVIEWER_TEXT));
297
+ assert.equal(result.resultByteLength, Buffer.byteLength(REVIEWER_TEXT));
312
298
  assert.equal(JSON.parse(result.submission).admission_decision, "completed");
313
299
 
314
- // Prompt bytes reached pi stdin verbatim.
315
- assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
316
- // Submission --input file bytes are EXACTLY the reviewer's assistant text.
317
- assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(PI_REVIEWER_TEXT, "utf8"));
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"));
318
305
 
319
306
  const gentleAiCalls = readLog(fixture.logPath);
320
307
  assert.equal(gentleAiCalls.length, 2);
321
308
  // (a) exact provider tokens, verbatim, in provider order.
322
309
  assert.deepEqual(gentleAiCalls[0]!.argv, ["review", "capture-result", ...CAPTURE_TOKENS]);
323
- // (d) the provider-owned submission form, verbatim: its exact operation
310
+ // (b) the provider-owned submission form, verbatim: its exact operation
324
311
  // and argument tokens with only the artifact path substituted into the
325
312
  // declared {{value}} slot. No agent/materialize, nothing synthesized.
326
313
  assert.deepEqual(gentleAiCalls[1]!.argv.slice(0, 2 + BINDING_TOKENS.length), ["review", SUBMISSION.operationToken, ...BINDING_TOKENS]);
@@ -334,83 +321,74 @@ test("relay happy path moves prompt bytes verbatim and the pi event stream's ass
334
321
  // environment carried none.
335
322
  assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
336
323
  assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
337
-
338
- const piCalls = readLog(fixture.piLogPath);
339
- assert.equal(piCalls.length, 1);
340
- // The pi environment stays untouched: no relay handshake is injected.
341
- assert.equal(piCalls[0]!.contract, null);
342
- // Fresh EMPTY scratch cwd, removed after the run.
343
- assert.deepEqual(piCalls[0]!.entries, []);
344
- assert.notEqual(piCalls[0]!.cwd, process.cwd());
345
- assert.equal(existsSync(piCalls[0]!.cwd!), false);
346
324
  });
347
325
 
348
- test("the pi lockdown argv is pinned exactly with no default 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) => {
349
332
  const fixture = harness(t);
350
- await runReviewHostRelaySlot(relayRequest(fixture));
351
- const piCalls = readLog(fixture.piLogPath);
352
- const expected = [
353
- "--print",
354
- "--mode", "json",
355
- "--no-session",
356
- "--no-tools",
357
- "--no-extensions",
358
- "--no-skills",
359
- "--no-prompt-templates",
360
- "--no-themes",
361
- "--no-context-files",
362
- "--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)}`,
363
338
  ];
364
- assert.deepEqual([...REVIEW_HOST_RELAY_PI_ARGV], expected);
365
- assert.deepEqual(piCalls[0]!.argv, expected);
366
- // The default launch carries no selection; a caller-owned selection may only
367
- // ride the validated forwarding path, never the pinned argv.
368
- 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+$/);
369
357
  });
370
358
 
371
359
  test("preparation snapshots mutable submission tokens and values before materialization", async (t) => {
372
360
  const fixture = harness(t);
373
- const barrierDirectory = join(fixture.directory, "submission-snapshot-barrier");
374
- const releaseFile = join(fixture.directory, "submission-snapshot-release");
375
361
  const submissionTokens = [...SUBMISSION.argumentTokens];
376
362
  const submissionValues = SUBMISSION.values.map((value) => ({ ...value }));
377
363
  const submission: ReviewCaptureSubmissionV1 = { ...SUBMISSION, argumentTokens: submissionTokens, values: submissionValues };
378
- const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, {
379
- submission,
380
- environment: {
381
- ...fixture.environment,
382
- RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
383
- RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
384
- RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
385
- RELAY_FAKE_PI_BARRIER_COUNT: "1",
386
- RELAY_FAKE_PI_RELEASE_FILE: releaseFile,
387
- },
388
- }));
389
- 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");
390
374
  submissionTokens[submissionTokens.length - 1] = "--input=mutated";
391
375
  submissionValues[0]!.slot = "mutated";
392
- writeFileSync(releaseFile, "release");
376
+ releaseCompletion!();
393
377
 
394
378
  const result = await prepared;
395
379
  assert.deepEqual(result.request.submission, SUBMISSION);
396
380
  await submitReviewHostRelayPreparedResult(result);
397
- assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(PI_REVIEWER_TEXT, "utf8"));
381
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(REVIEWER_TEXT, "utf8"));
398
382
  });
399
383
 
400
384
  test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
401
385
  const fixture = harness(t);
402
- const reviewerBytes = Buffer.from(PI_OUTPUT_BYTES);
403
- const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), async () => ({
404
- stdout: reviewerBytes,
405
- promptByteLength: PROMPT_BYTES.length,
406
- stdoutByteLength: reviewerBytes.length,
407
- }));
408
- reviewerBytes.fill(0);
386
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), textReviewer(REVIEWER_TEXT).runReviewer);
409
387
  const mutablePrepared = prepared as unknown as { resultBytes?: Buffer };
410
388
  mutablePrepared.resultBytes?.fill(0);
411
389
  assert.throws(() => { mutablePrepared.resultBytes = Buffer.from("fabricated"); }, TypeError);
412
390
  await submitReviewHostRelayPreparedResult(prepared);
413
- assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
391
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(REVIEWER_TEXT, "utf8"));
414
392
 
415
393
  const fabricated = { ...prepared, resultBytes: Buffer.from("fabricated") } as unknown as ReviewHostRelayPreparedResult;
416
394
  await assert.rejects(submitReviewHostRelayPreparedResult(fabricated), /recognized prepared result/);
@@ -420,7 +398,7 @@ test("preparation keeps reviewer bytes private through deferred submission", asy
420
398
  test("the supplied AbortSignal stays live through deferred submission", async (t) => {
421
399
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_DELAY_MS: "1000" });
422
400
  const controller = new AbortController();
423
- const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }));
401
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }), textReviewer(REVIEWER_TEXT).runReviewer);
424
402
  const submission = submitReviewHostRelayPreparedResult(prepared);
425
403
  await waitFor(() => readLog(fixture.logPath).length === 2, "submission did not start");
426
404
  controller.abort();
@@ -429,21 +407,22 @@ test("the supplied AbortSignal stays live through deferred submission", async (t
429
407
 
430
408
  test("four reviewers cross a shared barrier before any result submission can begin", async (t) => {
431
409
  const fixture = harness(t);
432
- const barrierDirectory = join(fixture.directory, "reviewer-barrier");
433
- const requests = reviewerGroupRequests(fixture).map((request) => ({
434
- ...request,
435
- environment: {
436
- ...request.environment,
437
- RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
438
- RELAY_FAKE_PI_BARRIER_COUNT: String(REVIEWER_GROUP_LENSES.length),
439
- },
440
- }));
441
-
442
- 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;
443
423
 
444
424
  assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
445
- assert.ok(prepared.every((result) => result.resultByteLength === Buffer.byteLength(PI_REVIEWER_TEXT)));
446
- assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
425
+ assert.ok(prepared.every((result) => result.resultByteLength === Buffer.byteLength(REVIEWER_TEXT)));
447
426
  assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
448
427
  });
449
428
 
@@ -511,106 +490,133 @@ test("partial reviewer failures wait for a later pending transport and report th
511
490
  // Fail-closed legs — a typed transport error and NO submission.
512
491
  // ---------------------------------------------------------------------------
513
492
 
514
- 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) => {
515
494
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "fail" });
516
- 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");
517
497
  assert.equal(error.exitCode, 3);
518
498
  assert.equal(error.mutationOutcome, "none");
519
499
  assert.equal(readLog(fixture.logPath).length, 1);
520
- assert.equal(readLog(fixture.piLogPath).length, 0);
500
+ assert.equal(calls.length, 0, "no completion may run after a materialize failure");
521
501
  assert.equal(existsSync(fixture.submitCapturePath), false);
522
502
  });
523
503
 
524
- 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) => {
525
505
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "empty" });
526
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize");
527
- 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);
528
509
  assert.equal(existsSync(fixture.submitCapturePath), false);
529
510
  });
530
511
 
531
- test("pi nonzero exit fails closed with a typed error and no submission", async (t) => {
532
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
533
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
534
- assert.equal(error.exitCode, 4);
535
- assert.equal(error.mutationOutcome, "none");
536
- assert.equal(readLog(fixture.logPath).length, 1, "no submission invocation after pi failure");
537
- 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
+ }
538
536
  });
539
537
 
540
- test("empty pi stdout fails closed with a typed error and no submission", async (t) => {
541
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "empty" });
542
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
543
- 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
+ );
544
545
  assert.equal(existsSync(fixture.submitCapturePath), false);
545
- // #1156: the envelope carries what the child's stream revealed, not just a
546
- // bare kind code.
547
- assert.match(error.message, /no assistant text|no output|not a pi event stream/);
546
+ assert.match(error.message, /tool call/);
548
547
  });
549
548
 
550
- test("a reviewer run that only attempted a tool call fails typed with the child's own evidence", async (t) => {
549
+ test("the relay forwards the caller-owned selection and thinking level to the completion request", async (t) => {
551
550
  const fixture = harness(t);
552
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, {
553
- environment: {
554
- ...fixture.environment,
555
- RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
556
- RELAY_FAKE_PI_OUTPUT_B64: piEventStream(
557
- { type: "message_end", message: { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "bash" }] } },
558
- assistantEnd("", "nan/deepseek-v4-flash"),
559
- ).toString("base64"),
560
- },
561
- })), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
562
- assert.equal(existsSync(fixture.submitCapturePath), false);
563
- assert.match(error.message, /no assistant text/);
564
- assert.ok((error as unknown as { reviewerEvidence?: { stdoutKind?: string } }).reviewerEvidence !== undefined, "the failure report must carry the reviewer evidence");
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");
565
557
  });
566
558
 
567
- test("the relay forwards the caller-owned reviewer selection and extension allowlist to the child argv", async (t) => {
559
+ // The in-process reviewer completion is an extension side-call that bypasses
560
+ // pi's main agent loop, so the relay must forward the caller's live session id
561
+ // into the completion request; the completion itself turns it into the
562
+ // OpenCode attribution headers (lib/inprocess-reviewer.ts). A absent session
563
+ // id forwards nothing and is never an error.
564
+ test("the relay forwards the caller's reviewer session id into the completion request", async (t) => {
568
565
  const fixture = harness(t);
569
- const adapterPath = join(fixture.directory, "auth-adapter.ts");
570
- writeFileSync(adapterPath, "export default () => {};");
571
- await runReviewHostRelaySlot(relayRequest(fixture, {
572
- reviewerModel: "minimax/MiniMax-M3",
573
- reviewerExtensionPaths: [adapterPath],
574
- }));
575
- const calls = readLog(fixture.piLogPath);
566
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
567
+ await runRelay(fixture, { reviewerSessionId: "ses-live-1" }, runReviewer);
576
568
  assert.equal(calls.length, 1);
577
- assert.deepEqual(calls[0]!.argv.slice(-4), ["--model", "minimax/MiniMax-M3", "-e", adapterPath]);
569
+ assert.equal(calls[0]!.sessionId, "ses-live-1");
578
570
  });
579
571
 
580
- test("a malformed reviewer selection or a relative extension path is refused typed before anything launches", async (t) => {
572
+ test("a relay request with no reviewer session id adds no session id to the completion request", async (t) => {
581
573
  const fixture = harness(t);
582
- for (const broken of [
583
- { reviewerModel: "minimax / MiniMax M3" },
584
- { reviewerModel: "" },
585
- { reviewerExtensionPaths: ["relative/auth.ts"] },
586
- { reviewerExtensionPaths: [join(fixture.directory, "missing-adapter.ts")] },
587
- ]) {
588
- let caught: unknown;
589
- try {
590
- await runReviewHostRelaySlot(relayRequest(fixture, broken));
591
- } catch (error) {
592
- caught = error;
593
- }
594
- assert.ok(caught instanceof ReviewHostRelayError, `expected a typed relay error for ${JSON.stringify(broken)}`);
595
- const error = caught as ReviewHostRelayError;
596
- assert.equal(error.kind, "reviewer-config-invalid");
597
- assert.equal(error.stage, "pi");
598
- assert.equal(readLog(fixture.piLogPath).length, 0, "no reviewer may launch on a broken selection");
599
- assert.equal(readLog(fixture.logPath).length, 0, "no materialization may run on a broken selection");
600
- }
574
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
575
+ await runRelay(fixture, {}, runReviewer);
576
+ assert.equal(calls.length, 1);
577
+ assert.equal(calls[0]!.sessionId, undefined);
601
578
  });
602
579
 
603
- test("pi timeout fails closed with a typed error and no submission", async (t) => {
604
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
605
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
606
- assert.equal(error.timedOut, true);
607
- assert.equal(readLog(fixture.logPath).length, 1);
580
+ test("a missing model registry is refused typed before materialize ever runs", async (t) => {
581
+ const fixture = harness(t);
582
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
583
+ const error = await rejectsWithRelayError(runRelay(fixture, { reviewerRegistry: undefined }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi");
584
+ assert.equal(calls.length, 0, "no completion may run on a broken configuration");
585
+ assert.equal(readLog(fixture.logPath).length, 0, "no materialization may run without a model registry");
586
+ assert.match(error.message, /model registry/);
587
+ });
588
+
589
+ test("a routing entry with no configured model is refused typed, naming the routing key, before materialize ever runs", async (t) => {
590
+ const fixture = harness(t);
591
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
592
+ const error = await rejectsWithRelayError(runRelay(fixture, { selection: undefined, routingKey: "review-risk" }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID, "pi");
593
+ assert.equal(calls.length, 0);
594
+ assert.equal(readLog(fixture.logPath).length, 0, "no materialization may run without a configured model");
595
+ assert.match(error.message, /review-risk/);
596
+ });
597
+
598
+ test("a tool call's cancelled signal maps to REVIEWER_ABORTED and forwards the caller's own signal to the completion", async (t) => {
599
+ const fixture = harness(t);
600
+ const controller = new AbortController();
601
+ let receivedSignal: AbortSignal | undefined;
602
+ // The signal aborts once the completion actually starts (materialize must
603
+ // already have succeeded with a live signal); this proves the same signal
604
+ // object reaches the completion request, not just that an early abort
605
+ // short-circuits materialize too.
606
+ const runReviewer = (async (request: InProcessReviewerRequest) => {
607
+ receivedSignal = request.signal;
608
+ controller.abort();
609
+ return { kind: "refused", code: INPROCESS_REVIEWER_FAILURE.ABORTED, message: "Reviewer completion for review-reliability was aborted by the caller." } as InProcessReviewerOutcome;
610
+ }) as typeof runInProcessReviewer;
611
+ const error = await rejectsWithRelayError(runRelay(fixture, { signal: controller.signal }, runReviewer), REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED, "pi");
612
+ assert.equal(receivedSignal, controller.signal, "the relay must forward the tool call's own signal into the completion request");
613
+ assert.match(error.message, /aborted/);
608
614
  assert.equal(existsSync(fixture.submitCapturePath), false);
609
615
  });
610
616
 
611
617
  // ---------------------------------------------------------------------------
612
618
  // gentle-pi#367 — the reviewer bound is reachable from production, and a
613
- // reviewer killed by it says so with both measurements.
619
+ // timed-out completion says so with both measurements.
614
620
  // ---------------------------------------------------------------------------
615
621
 
616
622
  test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
@@ -644,50 +650,26 @@ test("the reviewer bound honours the environment override and ignores malformed
644
650
  }
645
651
  });
646
652
 
647
- test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
648
- // The regression: piTimeoutMs was reachable only through the test seam, so
649
- // production always ran against the fixed 600s bound. This request injects
650
- // no timeout at all — the override must reach the real spawn.
651
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
653
+ test("the production relay path resolves the reviewer bound from the environment and forwards it to the completion, with no injected timeout", async (t) => {
654
+ // The regression this guards: piTimeoutMs was reachable only through the
655
+ // test seam, so production always ran against a fixed bound. This request
656
+ // injects no timeout at all — the override must reach the completion.
657
+ const fixture = harness(t, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300000" });
658
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
659
+ await runRelay(fixture, { piTimeoutMs: undefined }, runReviewer);
660
+ assert.equal(calls[0]!.timeoutMs, 300_000);
661
+ });
662
+
663
+ test("a timed-out reviewer completion reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
664
+ const fixture = harness(t);
652
665
  const error = await rejectsWithRelayError(
653
- runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
666
+ runRelay(fixture, { piTimeoutMs: 300 }, refusingReviewer(INPROCESS_REVIEWER_FAILURE.TIMED_OUT, "Reviewer completion for review-reliability exceeded its 300ms bound.", undefined, 20)),
654
667
  REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
655
668
  "pi",
656
669
  );
657
670
  assert.equal(error.timeoutMs, 300);
671
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 15, `elapsed ${error.elapsedMs} must record the real wall time`);
658
672
  assert.equal(error.timedOut, true);
659
- assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
660
- assert.equal(existsSync(fixture.submitCapturePath), false);
661
- });
662
-
663
- test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
664
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
665
- const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
666
- const originalTmpdir = process.env.TMPDIR;
667
- process.env.TMPDIR = scratchParent;
668
- try {
669
- const error = await rejectsWithRelayError(
670
- runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
671
- REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
672
- "pi",
673
- );
674
- assert.equal(error.timedOut, true);
675
- assert.equal(error.timeoutMs, 300);
676
- assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
677
- } finally {
678
- if (originalTmpdir === undefined) delete process.env.TMPDIR;
679
- else process.env.TMPDIR = originalTmpdir;
680
- chmodSync(scratchParent, 0o700);
681
- rmSync(scratchParent, { recursive: true, force: true });
682
- }
683
- });
684
-
685
- test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
686
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
687
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
688
- assert.equal(error.timeoutMs, 300);
689
- assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
690
- assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
691
673
  assert.match(error.message, /exceeded the relay bound/);
692
674
  assert.match(error.message, new RegExp(String(error.elapsedMs)));
693
675
  assert.match(error.message, /limit for a \d+-byte materialized prompt/);
@@ -695,18 +677,21 @@ test("a killed reviewer reports elapsed, limit, and what to change instead of an
695
677
  assert.equal(error.mutationOutcome, "none");
696
678
  });
697
679
 
698
- test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
699
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
700
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
680
+ test("a provider-failed completion stays distinguishable from a timed-out one and still carries its measurements", async (t) => {
681
+ const fixture = harness(t);
682
+ const error = await rejectsWithRelayError(
683
+ runRelay(fixture, { piTimeoutMs: 30_000 }, refusingReviewer(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, "Reviewer completion failed for review-reliability: upstream 500")),
684
+ REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
685
+ "pi",
686
+ );
701
687
  assert.equal(error.timedOut, false);
702
- assert.equal(error.exitCode, 4);
703
688
  assert.equal(error.timeoutMs, 30_000);
704
689
  assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
705
690
  });
706
691
 
707
692
  test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
708
693
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
709
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
694
+ const error = await rejectsWithRelayError(runRelay(fixture), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
710
695
  assert.equal(error.mutationOutcome, "unknown");
711
696
  assert.match(error.stderr, /capture binding does not match/);
712
697
  assert.equal(readLog(fixture.logPath).length, 2);
@@ -716,21 +701,14 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
716
701
  // typed [invalid_request] refusal and states that the lens slot was not
717
702
  // consumed. That is a proven non-mutation, not an unknown outcome, and the
718
703
  // refusal text is the only thing that tells the host what to change.
719
- function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
720
- return relayRequest(fixture, {
721
- environment: {
722
- ...fixture.environment,
723
- RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
724
- // The reviewer's payload arrives as the event stream's assistant text.
725
- RELAY_FAKE_PI_OUTPUT_B64: piEventStream(assistantEnd(reviewerOutput.toString("utf8"))).toString("base64"),
726
- },
727
- });
704
+ function admittingRequest(fixture: RelayHarness): ReviewHostRelayRequest {
705
+ return relayRequest(fixture);
728
706
  }
729
707
 
730
708
  test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
731
709
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
732
710
  const error = await rejectsWithRelayError(
733
- runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
711
+ runReviewHostRelaySlot(admittingRequest(fixture), textReviewer("not json at all").runReviewer),
734
712
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
735
713
  "submit",
736
714
  );
@@ -745,9 +723,9 @@ test("a reviewer printing garbage is refused at admission as a proven non-mutati
745
723
 
746
724
  test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
747
725
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
748
- const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
726
+ const wrongSubject = JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] });
749
727
  const error = await rejectsWithRelayError(
750
- runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
728
+ runReviewHostRelaySlot(admittingRequest(fixture), textReviewer(wrongSubject).runReviewer),
751
729
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
752
730
  "submit",
753
731
  );
@@ -762,7 +740,8 @@ test("a reviewer echoing a different subject is refused at admission as a proven
762
740
 
763
741
  test("a submission the fake admits with the expected subject still completes", async (t) => {
764
742
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
765
- const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
743
+ const expected = JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] });
744
+ const result = await runReviewHostRelaySlot(admittingRequest(fixture), textReviewer(expected).runReviewer);
766
745
  assert.equal(JSON.parse(result.submission).admission_decision, "completed");
767
746
  });
768
747
 
@@ -773,7 +752,7 @@ test("submission refusal preserves its primary evidence when result staging clea
773
752
  process.env.TMPDIR = scratchParent;
774
753
  try {
775
754
  const error = await rejectsWithRelayError(
776
- runReviewHostRelaySlot(relayRequest(fixture)),
755
+ runRelay(fixture),
777
756
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
778
757
  "submit",
779
758
  );
@@ -791,12 +770,10 @@ test("submission refusal preserves its primary evidence when result staging clea
791
770
  }
792
771
  });
793
772
 
794
- test("relay scratch and staging directories are removed after failures too", async (t) => {
795
- const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
796
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
797
- const piCalls = readLog(fixture.piLogPath);
798
- assert.equal(piCalls.length, 1);
799
- assert.equal(existsSync(piCalls[0]!.cwd!), false);
773
+ test("relay staging directories are removed after a submission failure too", async (t) => {
774
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
775
+ await rejectsWithRelayError(runRelay(fixture), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
776
+ assert.equal(readLog(fixture.logPath).length, 2);
800
777
  });
801
778
 
802
779
  test("a result staging cleanup failure remains a typed submit failure", async (t) => {
@@ -806,7 +783,7 @@ test("a result staging cleanup failure remains a typed submit failure", async (t
806
783
  process.env.TMPDIR = scratchParent;
807
784
  try {
808
785
  const error = await rejectsWithRelayError(
809
- runReviewHostRelaySlot(relayRequest(fixture)),
786
+ runRelay(fixture),
810
787
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
811
788
  "submit",
812
789
  );
@@ -826,20 +803,22 @@ test("a result staging cleanup failure remains a typed submit failure", async (t
826
803
 
827
804
  test("an old binary's unknown-flag refusal classifies as relay-unavailable with the exact report", async (t) => {
828
805
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "unknown-flag" });
829
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize");
806
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
807
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize");
830
808
  assert.equal(error.message, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE);
831
809
  assert.match(error.stderr, /flag provided but not defined: -materialize/);
832
- assert.equal(readLog(fixture.piLogPath).length, 0);
810
+ assert.equal(calls.length, 0);
833
811
  assert.equal(existsSync(fixture.submitCapturePath), false);
834
812
  });
835
813
 
836
814
  test("a handshake refusal surfaces the provider refusal verbatim", async (t) => {
837
815
  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";
838
816
  const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "handshake", RELAY_FAKE_HANDSHAKE_STDERR: refusal });
839
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize");
817
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
818
+ const error = await rejectsWithRelayError(runRelay(fixture, {}, runReviewer), REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize");
840
819
  assert.equal(error.message, refusal);
841
820
  assert.equal(error.stderr, refusal);
842
- assert.equal(readLog(fixture.piLogPath).length, 0);
821
+ assert.equal(calls.length, 0);
843
822
  });
844
823
 
845
824
  test("refusal classification distinguishes unknown-flag, handshake, and other", () => {
@@ -854,7 +833,7 @@ test("refusal classification distinguishes unknown-flag, handshake, and other",
854
833
  // deterministic for this exact slot. Generic admission refusals are repairable
855
834
  // by a fresh reviewer and retain the ordinary exact-reoffer behavior.
856
835
  test("the unachievable predicate names only deterministic slot failures", () => {
857
- 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 });
836
+ 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 });
858
837
  assert.equal(reviewHostRelayUnachievableReason(killed), "relay_transport_bound_exceeded");
859
838
  assert.equal(reviewHostRelayUnachievableDetail(killed), "killed after 2256004ms against a 2256000ms relay bound");
860
839
 
@@ -868,7 +847,24 @@ test("the unachievable predicate names only deterministic slot failures", () =>
868
847
  assert.equal(reviewHostRelayUnachievableReason(unknownOutcome), undefined, "an unknown mutation outcome is never deterministic");
869
848
  assert.equal(reviewHostRelayUnachievableDetail(unknownOutcome), undefined);
870
849
 
871
- 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]) {
850
+ for (const kind of [
851
+ REVIEW_HOST_RELAY_FAILURE.PI_FAILED,
852
+ REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
853
+ REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT,
854
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_MODEL_NOT_FOUND,
855
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_AUTH_UNAVAILABLE,
856
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_THINKING_INVALID,
857
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_TOOL_CALL,
858
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_EMPTY_OUTPUT,
859
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_OUTPUT_TOO_LARGE,
860
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_ABORTED,
861
+ REVIEW_HOST_RELAY_FAILURE.REVIEWER_CONFIG_INVALID,
862
+ REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED,
863
+ REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT,
864
+ REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE,
865
+ REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED,
866
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
867
+ ]) {
872
868
  assert.equal(reviewHostRelayUnachievableReason(new ReviewHostRelayError(kind, "pi", "transient")), undefined, `${kind} stays transient`);
873
869
  }
874
870
 
@@ -942,37 +938,39 @@ test("relay input validation rejects empty or malformed inputs before any proces
942
938
 
943
939
  test("a materialize slot without a provider submission fails closed before any process launches", async (t) => {
944
940
  const fixture = harness(t);
941
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
945
942
  const error = await rejectsWithRelayError(
946
- runReviewHostRelaySlot(relayRequest(fixture, { submission: undefined })),
943
+ runRelay(fixture, { submission: undefined }, runReviewer),
947
944
  REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
948
945
  "binding",
949
946
  );
950
947
  assert.equal(error.message, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
951
948
  assert.equal(error.mutationOutcome, "none");
952
949
  assert.equal(readLog(fixture.logPath).length, 0, "no gentle-ai invocation was launched");
953
- assert.equal(readLog(fixture.piLogPath).length, 0, "no pi subprocess was launched");
950
+ assert.equal(calls.length, 0, "no completion was run");
954
951
  assert.equal(existsSync(fixture.submitCapturePath), false);
955
952
  });
956
953
 
957
954
  test("a submission form the relay cannot bind is a typed contract mismatch, never a repaired invocation", async (t) => {
958
955
  const fixture = harness(t);
956
+ const { runReviewer, calls } = textReviewer(REVIEWER_TEXT);
959
957
  const twoValues: ReviewCaptureSubmissionV1 = {
960
958
  ...SUBMISSION,
961
959
  values: [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }],
962
960
  };
963
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: twoValues })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
961
+ await rejectsWithRelayError(runRelay(fixture, { submission: twoValues }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
964
962
  const noSlot: ReviewCaptureSubmissionV1 = {
965
963
  ...SUBMISSION,
966
964
  argumentTokens: [...BINDING_TOKENS, "--input=/etc/somewhere"],
967
965
  };
968
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: noSlot })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
966
+ await rejectsWithRelayError(runRelay(fixture, { submission: noSlot }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
969
967
  const outOfBounds: ReviewCaptureSubmissionV1 = {
970
968
  ...SUBMISSION,
971
969
  values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: SUBMISSION.argumentTokens.length }],
972
970
  };
973
- await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: outOfBounds })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
971
+ await rejectsWithRelayError(runRelay(fixture, { submission: outOfBounds }, runReviewer), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
974
972
  assert.equal(readLog(fixture.logPath).length, 0);
975
- assert.equal(readLog(fixture.piLogPath).length, 0);
973
+ assert.equal(calls.length, 0);
976
974
  });
977
975
 
978
976
  test("resolveReviewHostRelaySubmission returns the provider binding untouched", () => {