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.
- package/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- 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
|
|
35
|
-
// wrappers in related review tests),
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
305
|
-
assert.
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
// (
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
"
|
|
344
|
-
"
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
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),
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 ===
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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("
|
|
529
|
-
const fixture = harness(t
|
|
530
|
-
await rejectsWithRelayError(
|
|
531
|
-
|
|
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("
|
|
536
|
-
const fixture = harness(t
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
assert.equal(
|
|
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
|
-
//
|
|
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
|
|
581
|
-
// production always ran against
|
|
582
|
-
// no timeout at all — the override must reach the
|
|
583
|
-
const fixture = harness(t, {
|
|
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
|
-
|
|
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
|
|
631
|
-
const fixture = harness(t
|
|
632
|
-
const error = await rejectsWithRelayError(
|
|
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(
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
726
|
-
const fixture = harness(t, {
|
|
727
|
-
await rejectsWithRelayError(
|
|
728
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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", "
|
|
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 [
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
952
|
+
assert.equal(calls.length, 0);
|
|
907
953
|
});
|
|
908
954
|
|
|
909
955
|
test("resolveReviewHostRelaySubmission returns the provider binding untouched", () => {
|