gentle-pi 3.1.1 → 3.2.1
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 +18 -11
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +15 -5
- package/docs/readme-reference.md +43 -18
- package/docs/review-integration.md +15 -6
- package/extensions/gentle-agents.ts +22 -1
- package/extensions/gentle-ai.ts +103 -143
- package/extensions/gentle-shell.ts +29 -7
- package/lib/background-subagents-policy.ts +148 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +18 -0
- package/lib/opaque-pi-reviewer-adapter.ts +130 -10
- package/lib/review-candidate-view.ts +27 -6
- package/lib/review-host-relay.ts +95 -2
- package/lib/shell-bar.ts +63 -9
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +18 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/skills/chained-pr/SKILL.md +2 -1
- package/skills/work-unit-commits/SKILL.md +9 -0
- package/tests/background-subagents-default-mode.test.ts +105 -0
- package/tests/gentle-agents.test.ts +14 -1
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-shell.test.ts +109 -3
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +101 -2
- package/tests/opaque-pi-reviewer-adapter.test.ts +153 -9
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-base-ref-hint.test.ts +39 -0
- package/tests/review-candidate-view.test.ts +55 -0
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay.test.ts +83 -14
- package/tests/review-relay-transport-agent.test.ts +86 -1
- package/tests/shell-bar.test.ts +153 -3
- package/tests/shell-usage-view.test.ts +3 -2
- package/tests/shell-usage.test.ts +254 -6
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
OPAQUE_PI_REVIEWER_ARGV,
|
|
9
9
|
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
|
|
10
10
|
OpaquePiReviewerTransportError,
|
|
11
|
+
extractPiAssistantText,
|
|
11
12
|
resolvePiLaunch,
|
|
12
13
|
runOpaquePiReviewer,
|
|
13
14
|
} from "../lib/opaque-pi-reviewer-adapter.ts";
|
|
@@ -43,10 +44,12 @@ const PROMPT_BYTES = Buffer.concat([
|
|
|
43
44
|
Buffer.from("opaque prompt\r\n\u0000", "utf8"),
|
|
44
45
|
Buffer.from([0x01, 0xff, 0xfe, 0x00]),
|
|
45
46
|
]);
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
const DEFAULT_OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\n";
|
|
48
|
+
|
|
49
|
+
const OUTPUT_BYTES = piEventStream(
|
|
50
|
+
...userTurn("opaque prompt"),
|
|
51
|
+
...assistantTurn([{ type: "text", text: DEFAULT_OUTPUT_TEXT }]),
|
|
52
|
+
);
|
|
50
53
|
|
|
51
54
|
interface OpaqueHarness {
|
|
52
55
|
directory: string;
|
|
@@ -94,6 +97,34 @@ function readLog(path: string): OpaquePiLog[] {
|
|
|
94
97
|
.map((line) => JSON.parse(line) as OpaquePiLog);
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
// The reviewer child runs `pi --mode json`, so its stdout is a newline-
|
|
101
|
+
// delimited pi event stream. These helpers build the minimal stream shapes the
|
|
102
|
+
// extraction has to understand, including the shapes behind the empty-output
|
|
103
|
+
// field reports (#1140: a reviewer that answers with a tool call writes zero
|
|
104
|
+
// usable bytes in text mode and exits 0 in silence).
|
|
105
|
+
function piEventStream(...events: readonly unknown[]): Buffer {
|
|
106
|
+
return Buffer.from(events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function userTurn(text: string): unknown[] {
|
|
110
|
+
const message = { role: "user", content: [{ type: "text", text }] };
|
|
111
|
+
return [
|
|
112
|
+
{ type: "message_start", message },
|
|
113
|
+
{ type: "message_end", message },
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assistantTurn(parts: readonly Record<string, unknown>[], reviewerModel?: string): unknown[] {
|
|
118
|
+
const content = parts.map((part) => ({ ...part }));
|
|
119
|
+
const message: Record<string, unknown> = { role: "assistant", content };
|
|
120
|
+
if (reviewerModel !== undefined) message.model = reviewerModel;
|
|
121
|
+
return [
|
|
122
|
+
{ type: "message_start", message },
|
|
123
|
+
{ type: "message_end", message },
|
|
124
|
+
{ type: "agent_end", messages: [message] },
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
97
128
|
async function rejectsWithTransportError(
|
|
98
129
|
promise: Promise<unknown>,
|
|
99
130
|
kind: (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE],
|
|
@@ -107,8 +138,22 @@ async function rejectsWithTransportError(
|
|
|
107
138
|
return caught!;
|
|
108
139
|
}
|
|
109
140
|
|
|
110
|
-
|
|
111
|
-
|
|
141
|
+
// #1140/#1156: the child runs `pi --mode json` and the transport emits the
|
|
142
|
+
// final assistant text of that event stream. The prompt side stays byte-
|
|
143
|
+
// verbatim; the output side is pi's own envelope, so a silent or unintelligible
|
|
144
|
+
// run becomes a typed, evidenced failure instead of zero bytes and exit 0.
|
|
145
|
+
const OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\nsecond assistant text part";
|
|
146
|
+
|
|
147
|
+
function eventStreamFor(text: string): Buffer {
|
|
148
|
+
return piEventStream(
|
|
149
|
+
...userTurn("opaque prompt"),
|
|
150
|
+
...assistantTurn([{ type: "text", text }], "pi-test-model"),
|
|
151
|
+
{ type: "agent_settled" },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("the opaque adapter streams the prompt verbatim and emits the pi event stream's final assistant text", async (t) => {
|
|
156
|
+
const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor(OUTPUT_TEXT).toString("base64") });
|
|
112
157
|
const result = await runOpaquePiReviewer(PROMPT_BYTES, {
|
|
113
158
|
piExecutable: fixture.pi,
|
|
114
159
|
environment: fixture.environment,
|
|
@@ -116,8 +161,8 @@ test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim thro
|
|
|
116
161
|
});
|
|
117
162
|
|
|
118
163
|
assert.equal(result.promptByteLength, PROMPT_BYTES.length);
|
|
119
|
-
assert.equal(result.stdoutByteLength,
|
|
120
|
-
assert.deepEqual(result.stdout,
|
|
164
|
+
assert.equal(result.stdoutByteLength, Buffer.byteLength(OUTPUT_TEXT));
|
|
165
|
+
assert.deepEqual(result.stdout, Buffer.from(OUTPUT_TEXT, "utf8"));
|
|
121
166
|
assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
|
|
122
167
|
|
|
123
168
|
const calls = readLog(fixture.logPath);
|
|
@@ -129,6 +174,100 @@ test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim thro
|
|
|
129
174
|
assert.equal(existsSync(calls[0]!.cwd), false, "the empty scratch directory is removed after success");
|
|
130
175
|
});
|
|
131
176
|
|
|
177
|
+
test("the extraction mirrors the pi event stream: every assistant text part in order, nothing else", () => {
|
|
178
|
+
const streamed = piEventStream(
|
|
179
|
+
...userTurn("prompt"),
|
|
180
|
+
...assistantTurn([{ type: "thinking", thinking: "hidden" }, { type: "text", text: "first " }], "pi-a"),
|
|
181
|
+
...assistantTurn([{ type: "text", text: "second" }], "pi-b"),
|
|
182
|
+
);
|
|
183
|
+
const extracted = extractPiAssistantText(streamed);
|
|
184
|
+
assert.equal(extracted.kind, "text");
|
|
185
|
+
if (extracted.kind === "text") assert.equal(extracted.text, "first second");
|
|
186
|
+
|
|
187
|
+
const single = extractPiAssistantText(eventStreamFor("{\"findings\": []}"));
|
|
188
|
+
assert.equal(single.kind, "text");
|
|
189
|
+
if (single.kind === "text") assert.equal(single.text, "{\"findings\": []}");
|
|
190
|
+
|
|
191
|
+
// A stream pi could not have produced is not silently passed through.
|
|
192
|
+
assert.equal(extractPiAssistantText(Buffer.from("garbage, not an event stream", "utf8")).kind, "none");
|
|
193
|
+
assert.equal(extractPiAssistantText(Buffer.alloc(0)).kind, "none");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a silent or unintelligible reviewer run fails typed with the evidence the event stream carries", async (t) => {
|
|
197
|
+
const fixture = harness(t);
|
|
198
|
+
|
|
199
|
+
const silent = await rejectsWithTransportError(
|
|
200
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "empty" }, timeoutMs: 10_000 }),
|
|
201
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
202
|
+
);
|
|
203
|
+
assert.equal(silent.evidence?.stdoutKind, "no-output");
|
|
204
|
+
|
|
205
|
+
const unintelligible = await rejectsWithTransportError(
|
|
206
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: Buffer.from("not json at all", "utf8").toString("base64") }, timeoutMs: 10_000 }),
|
|
207
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
208
|
+
);
|
|
209
|
+
assert.equal(unintelligible.evidence?.stdoutKind, "not-a-pi-event-stream");
|
|
210
|
+
assert.match(unintelligible.message, /no assistant text|not a pi event stream|no output/i);
|
|
211
|
+
|
|
212
|
+
const textless = await rejectsWithTransportError(
|
|
213
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: piEventStream(...userTurn("prompt"), { type: "agent_settled" }).toString("base64") }, timeoutMs: 10_000 }),
|
|
214
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
215
|
+
);
|
|
216
|
+
assert.equal(textless.evidence?.stdoutKind, "no-assistant-text");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a reviewer run that spent its turn on a tool call reports that in the evidence instead of vanishing", async (t) => {
|
|
220
|
+
const fixture = harness(t, {
|
|
221
|
+
OPAQUE_PI_OUTPUT_B64: piEventStream(
|
|
222
|
+
...userTurn("prompt"),
|
|
223
|
+
...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }], "nan/deepseek-v4-flash"),
|
|
224
|
+
).toString("base64"),
|
|
225
|
+
});
|
|
226
|
+
const error = await rejectsWithTransportError(
|
|
227
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 }),
|
|
228
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
229
|
+
);
|
|
230
|
+
assert.equal(error.evidence?.stdoutKind, "no-assistant-text");
|
|
231
|
+
assert.equal(error.evidence?.toolCallAttempted, true);
|
|
232
|
+
assert.equal(error.evidence?.reviewerModel, "nan/deepseek-v4-flash");
|
|
233
|
+
assert.match(error.message, /tool call/i);
|
|
234
|
+
assert.match(error.message, /nan\/deepseek-v4-flash/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a tool-call attempt followed by text still yields the text", async (t) => {
|
|
238
|
+
const fixture = harness(t, {
|
|
239
|
+
OPAQUE_PI_OUTPUT_B64: piEventStream(
|
|
240
|
+
...userTurn("prompt"),
|
|
241
|
+
...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }]),
|
|
242
|
+
...assistantTurn([{ type: "text", text: "{\"verdict\": \"pass\"}" }], "nan/glm5.3-flash"),
|
|
243
|
+
).toString("base64"),
|
|
244
|
+
});
|
|
245
|
+
const result = await runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 });
|
|
246
|
+
assert.deepEqual(result.stdout, Buffer.from("{\"verdict\": \"pass\"}", "utf8"));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("caller-owned launch arguments ride the frozen argv verbatim and are validated before spawn", async (t) => {
|
|
250
|
+
const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor("ok").toString("base64") });
|
|
251
|
+
await runOpaquePiReviewer(PROMPT_BYTES, {
|
|
252
|
+
piExecutable: fixture.pi,
|
|
253
|
+
environment: fixture.environment,
|
|
254
|
+
timeoutMs: 10_000,
|
|
255
|
+
extraArguments: ["--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"],
|
|
256
|
+
});
|
|
257
|
+
const calls = readLog(fixture.logPath);
|
|
258
|
+
assert.equal(calls.length, 1);
|
|
259
|
+
assert.deepEqual(calls[0]!.argv, [...OPAQUE_PI_REVIEWER_ARGV, "--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"]);
|
|
260
|
+
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: ["--model", ""] }),
|
|
263
|
+
/non-empty/,
|
|
264
|
+
);
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: [42 as unknown as string] }),
|
|
267
|
+
/non-empty/,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
132
271
|
test("the opaque adapter returns typed transport errors for launch, nonzero, empty, timeout, and cancellation", async (t) => {
|
|
133
272
|
const fixture = harness(t);
|
|
134
273
|
await rejectsWithTransportError(
|
|
@@ -228,8 +367,13 @@ test("the opaque adapter has no review lifecycle imports or identifiers", () =>
|
|
|
228
367
|
const adapterPath = fileURLToPath(new URL("../lib/opaque-pi-reviewer-adapter.ts", import.meta.url));
|
|
229
368
|
const source = readFileSync(adapterPath, "utf8");
|
|
230
369
|
assert.doesNotMatch(source, /review-integration|gentle-ai|materialize|submit/i);
|
|
370
|
+
// The guard reads source, not strings: the one lifecycle-shaped word the
|
|
371
|
+
// transport may spell is pi's own quoted wire key for the selection the
|
|
372
|
+
// child reports in its events (#1140). It is data there, never an
|
|
373
|
+
// identifier, so it is stripped before the scan.
|
|
374
|
+
const withoutWireKeys = source.replaceAll('"model"', '""');
|
|
231
375
|
for (const identifier of ["lineage", "target", "revision", "receipt", "lens", "order", "subject", "schema", "capture", "submission", "status", "model", "provider", "profile"]) {
|
|
232
|
-
assert.doesNotMatch(
|
|
376
|
+
assert.doesNotMatch(withoutWireKeys, new RegExp(`\\b${identifier}\\b`, "i"), `adapter must not contain lifecycle identifier ${identifier}`);
|
|
233
377
|
}
|
|
234
378
|
});
|
|
235
379
|
|
|
@@ -309,20 +309,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
309
309
|
);
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
test("package verification binds the published Gentle AI v3.
|
|
312
|
+
test("package verification binds the published Gentle AI v3.2.1 runtime pin", () => {
|
|
313
313
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
314
314
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
315
315
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
316
316
|
|
|
317
|
-
assert.match(installer, /INSTALLER_VERSION = "3\.
|
|
317
|
+
assert.match(installer, /INSTALLER_VERSION = "3\.2\.1"/);
|
|
318
318
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
319
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
319
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:0QFo0ERv8\/3lgTepEG0E\/P7yD9qXXCx2M7ppQviVHMo="/);
|
|
320
320
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
321
321
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
322
322
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
323
323
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
324
324
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
325
|
-
assert.match(verifier, /v3\.
|
|
325
|
+
assert.match(verifier, /v3\.2\.1/);
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
|
|
@@ -1574,9 +1574,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1574
1574
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1575
1575
|
});
|
|
1576
1576
|
|
|
1577
|
-
test("Gentle Shell v3.
|
|
1577
|
+
test("Gentle Shell v3.2.1 package and runtime stop before publication", () => {
|
|
1578
1578
|
const packageJson = readPackageJson();
|
|
1579
|
-
assert.equal(packageJson.version, "3.
|
|
1579
|
+
assert.equal(packageJson.version, "3.2.1", "the release manifest must remain explicitly pinned to v3.2.1");
|
|
1580
1580
|
assert.equal(
|
|
1581
1581
|
packageJson.scripts?.test,
|
|
1582
1582
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
4
|
+
import { BASE_REF_ACCEPTED_FORMS } from "../lib/review-candidate-view.ts";
|
|
5
|
+
|
|
6
|
+
const BASE_REF_REJECTION_REASONS = ["base-ref-unresolvable", "base-ref-ambiguous", "base-ref-moved", "base-ref-invalid"];
|
|
7
|
+
|
|
8
|
+
test("a blocked native START base-ref rejection carries the accepted-forms hint", () => {
|
|
9
|
+
for (const reason of BASE_REF_REJECTION_REASONS) {
|
|
10
|
+
const result = __testing.nativeStartRejection(reason);
|
|
11
|
+
assert.equal(result.status, "blocked", reason);
|
|
12
|
+
assert.equal(result.reason, reason, reason);
|
|
13
|
+
assert.equal(result.hint, BASE_REF_ACCEPTED_FORMS, reason);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a blocked native START rejection for a reason unrelated to base refs carries no hint", () => {
|
|
18
|
+
const result = __testing.nativeStartRejection("committed-only-required");
|
|
19
|
+
assert.equal(result.status, "blocked");
|
|
20
|
+
assert.equal(result.reason, "committed-only-required");
|
|
21
|
+
assert.equal("hint" in result, false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a blocked native STATUS base-ref rejection carries the accepted-forms hint", () => {
|
|
25
|
+
for (const reason of BASE_REF_REJECTION_REASONS) {
|
|
26
|
+
const result = __testing.nativeStatusInputRejection(reason);
|
|
27
|
+
assert.equal(result.status, "blocked", reason);
|
|
28
|
+
assert.equal(result.outcome, "native-status-input-invalid", reason);
|
|
29
|
+
assert.equal(result.reason, reason, reason);
|
|
30
|
+
assert.equal(result.hint, BASE_REF_ACCEPTED_FORMS, reason);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a blocked native STATUS rejection for a reason unrelated to base refs carries no hint", () => {
|
|
35
|
+
const result = __testing.nativeStatusInputRejection("committed-only-required");
|
|
36
|
+
assert.equal(result.status, "blocked");
|
|
37
|
+
assert.equal(result.reason, "committed-only-required");
|
|
38
|
+
assert.equal("hint" in result, false);
|
|
39
|
+
});
|
|
@@ -10,6 +10,7 @@ import { join, resolve } from "node:path";
|
|
|
10
10
|
import test from "node:test";
|
|
11
11
|
import { gzipSync } from "node:zlib";
|
|
12
12
|
import {
|
|
13
|
+
BASE_REF_ACCEPTED_FORMS,
|
|
13
14
|
CandidateViewRegistry,
|
|
14
15
|
CandidateViewError,
|
|
15
16
|
hasExpectedExecutableBits,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
digestChangedPathManifest,
|
|
21
22
|
injectReviewCandidateView,
|
|
22
23
|
readCandidateContextManifestPage,
|
|
24
|
+
resolveCanonicalCandidateBase,
|
|
23
25
|
type NativeCandidateProjectionDescriptor,
|
|
24
26
|
} from "../lib/review-candidate-view.ts";
|
|
25
27
|
import { assertCandidateOwnerParent, assertTrustedWindowsOwner, prepareCandidateOwnerParent, setWindowsAclAuthorityForTesting, validatePrivateWindowsDacl, validatePrivateWindowsOwner, WindowsDaclValidationError, WindowsOwnerValidationError } from "../lib/review-candidate-view-owner.ts";
|
|
@@ -653,6 +655,59 @@ test("explicit base resolution preserves structured for-each-ref output-limit di
|
|
|
653
655
|
assert.doesNotMatch(failure.message, /sensitive base-reference output/);
|
|
654
656
|
});
|
|
655
657
|
|
|
658
|
+
test("resolving an abbreviated commit id as an explicit base ref names the accepted forms", (t) => {
|
|
659
|
+
const contributorRoot = repository(t);
|
|
660
|
+
const fullId = git(contributorRoot, "rev-parse", "HEAD");
|
|
661
|
+
const abbreviated = fullId.slice(0, 7);
|
|
662
|
+
assert.throws(
|
|
663
|
+
() => resolveCanonicalCandidateBase(contributorRoot, abbreviated),
|
|
664
|
+
(error: unknown) => {
|
|
665
|
+
assert.ok(error instanceof CandidateViewError);
|
|
666
|
+
assert.equal(error.reason, "base-ref-unresolvable");
|
|
667
|
+
assert.match(error.message, /accepted forms/);
|
|
668
|
+
assert.ok(error.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
669
|
+
assert.match(error.message, /abbreviated commit ids are not accepted/);
|
|
670
|
+
return true;
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("resolving an ambiguous explicit base ref names the accepted forms", (t) => {
|
|
676
|
+
const contributorRoot = repository(t);
|
|
677
|
+
git(contributorRoot, "tag", "dupe");
|
|
678
|
+
git(contributorRoot, "branch", "dupe");
|
|
679
|
+
assert.throws(
|
|
680
|
+
() => resolveCanonicalCandidateBase(contributorRoot, "dupe"),
|
|
681
|
+
(error: unknown) => {
|
|
682
|
+
assert.ok(error instanceof CandidateViewError);
|
|
683
|
+
assert.equal(error.reason, "base-ref-ambiguous");
|
|
684
|
+
assert.match(error.message, /accepted forms/);
|
|
685
|
+
assert.ok(error.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
686
|
+
return true;
|
|
687
|
+
},
|
|
688
|
+
);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("resolving an explicit base ref that names a tree, not a commit, reports it as unresolvable with the accepted forms instead of a generic git failure", (t) => {
|
|
692
|
+
const contributorRoot = repository(t);
|
|
693
|
+
const treeId = "b".repeat(40);
|
|
694
|
+
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
695
|
+
if (arguments_.at(-1) === `${treeId}^{commit}`) throw Object.assign(new Error("fatal: not a commit"), { status: 128, stdout: Buffer.from("") });
|
|
696
|
+
return execFileSync(file, arguments_, options);
|
|
697
|
+
};
|
|
698
|
+
let failure: unknown;
|
|
699
|
+
try {
|
|
700
|
+
new CandidateViewRegistry(executor).create({ contributorRoot, baseRef: treeId });
|
|
701
|
+
} catch (error) {
|
|
702
|
+
failure = error;
|
|
703
|
+
}
|
|
704
|
+
assert.ok(failure instanceof CandidateViewError);
|
|
705
|
+
assert.equal(failure.reason, "base-ref-unresolvable");
|
|
706
|
+
assert.match(failure.message, /it does not name a commit/);
|
|
707
|
+
assert.match(failure.message, /accepted forms/);
|
|
708
|
+
assert.ok(failure.message.includes(BASE_REF_ACCEPTED_FORMS));
|
|
709
|
+
});
|
|
710
|
+
|
|
656
711
|
test("every synchronous candidate-view Git command receives the explicit 64 MiB output limit", (t) => {
|
|
657
712
|
const calls: Array<{ arguments: readonly string[]; maxBuffer: number | undefined }> = [];
|
|
658
713
|
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
@@ -1171,6 +1171,7 @@ for (const statusSchema of [
|
|
|
1171
1171
|
|
|
1172
1172
|
test("inspect with untrackedScope exclude resolves the intended-untracked stop in one round trip", async (t) => {
|
|
1173
1173
|
const { cwd, initial, target, selection } = untrackedStopFixture(t);
|
|
1174
|
+
const baseRef = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
|
|
1174
1175
|
const requests: Array<Record<string, unknown>> = [],
|
|
1175
1176
|
retained = new Map();
|
|
1176
1177
|
const native = {
|
|
@@ -1180,7 +1181,11 @@ test("inspect with untrackedScope exclude resolves the intended-untracked stop i
|
|
|
1180
1181
|
},
|
|
1181
1182
|
} as unknown as NativeReviewCli;
|
|
1182
1183
|
const result = await __testing.executeReviewControllerOperation(
|
|
1183
|
-
{
|
|
1184
|
+
{
|
|
1185
|
+
operation: "inspect",
|
|
1186
|
+
input: JSON.stringify({ baseRef: "HEAD", committedOnly: true }),
|
|
1187
|
+
untrackedScope: "exclude",
|
|
1188
|
+
},
|
|
1184
1189
|
cwd,
|
|
1185
1190
|
native,
|
|
1186
1191
|
undefined,
|
|
@@ -1191,6 +1196,10 @@ test("inspect with untrackedScope exclude resolves the intended-untracked stop i
|
|
|
1191
1196
|
assert.equal(result.status, "ready");
|
|
1192
1197
|
assert.equal("selectionBinding" in result, false);
|
|
1193
1198
|
assert.equal(requests.length, 2);
|
|
1199
|
+
for (const request of requests) {
|
|
1200
|
+
assert.equal(request.baseRef, baseRef);
|
|
1201
|
+
assert.equal(request.committedOnly, true);
|
|
1202
|
+
}
|
|
1194
1203
|
assert.equal(requests[1]!.untrackedScope, "exclude");
|
|
1195
1204
|
assert.equal(requests[1]!.expectedUntrackedInventory, SHA);
|
|
1196
1205
|
assert.deepEqual(requests[1]!.intendedUntracked, []);
|
|
@@ -1960,6 +1969,56 @@ test("ordinary START transports native focus and safe policy inputs without rebu
|
|
|
1960
1969
|
assert.equal(statusCalls, 0);
|
|
1961
1970
|
});
|
|
1962
1971
|
|
|
1972
|
+
test("INSPECT forwards an explicit committed-only base selector to negotiated STATUS", async (t) => {
|
|
1973
|
+
const cwd = repository(t);
|
|
1974
|
+
const baseRef = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
|
|
1975
|
+
const requests: Array<Record<string, unknown>> = [];
|
|
1976
|
+
const native = {
|
|
1977
|
+
targetStatus: async (request: Record<string, unknown>) => {
|
|
1978
|
+
requests.push(request);
|
|
1979
|
+
return startStatus(cwd, baseRef);
|
|
1980
|
+
},
|
|
1981
|
+
} as unknown as NativeReviewCli;
|
|
1982
|
+
|
|
1983
|
+
const result = await __testing.executeReviewControllerOperation(
|
|
1984
|
+
{ operation: "inspect", input: JSON.stringify({ baseRef, committedOnly: true }) },
|
|
1985
|
+
cwd,
|
|
1986
|
+
native,
|
|
1987
|
+
);
|
|
1988
|
+
|
|
1989
|
+
assert.equal(result.status, "ready");
|
|
1990
|
+
assert.deepEqual(
|
|
1991
|
+
requests.map(({ baseRef: selectedBase, committedOnly }) => ({ baseRef: selectedBase, committedOnly })),
|
|
1992
|
+
[{ baseRef, committedOnly: true }],
|
|
1993
|
+
);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
test("INSPECT rejects malformed committed-range selectors before negotiated STATUS", async (t) => {
|
|
1997
|
+
const cwd = repository(t);
|
|
1998
|
+
let targetCalls = 0;
|
|
1999
|
+
const native = {
|
|
2000
|
+
targetStatus: async () => {
|
|
2001
|
+
targetCalls += 1;
|
|
2002
|
+
return startStatus(cwd);
|
|
2003
|
+
},
|
|
2004
|
+
} as unknown as NativeReviewCli;
|
|
2005
|
+
|
|
2006
|
+
for (const input of [
|
|
2007
|
+
{ baseRef: "HEAD", committedOnly: false },
|
|
2008
|
+
{ committedOnly: true },
|
|
2009
|
+
{ baseRef: "HEAD", committedOnly: true, mode: "ordinary" },
|
|
2010
|
+
]) {
|
|
2011
|
+
const rejected = await __testing.executeReviewControllerOperation(
|
|
2012
|
+
{ operation: "inspect", input: JSON.stringify(input) },
|
|
2013
|
+
cwd,
|
|
2014
|
+
native,
|
|
2015
|
+
);
|
|
2016
|
+
assert.equal(rejected.outcome, "native-inspect-input-invalid");
|
|
2017
|
+
assert.equal(rejected.mutation_outcome, "none");
|
|
2018
|
+
}
|
|
2019
|
+
assert.equal(targetCalls, 0);
|
|
2020
|
+
});
|
|
2021
|
+
|
|
1963
2022
|
test("ordinary START keeps default and explicit base selection fail-closed before native mutation", async (t) => {
|
|
1964
2023
|
const cwd = repository(t);
|
|
1965
2024
|
let targetCalls = 0;
|
|
@@ -214,10 +214,20 @@ const PROMPT_BYTES = Buffer.concat([
|
|
|
214
214
|
Buffer.from('GENTLE_AI_REVIEW_BINDING {"lineage":"review-1d5aadacc600e167"}\n"quotes" \\backslash\r\n\u00e9\u{1F3A9}\n', "utf8"),
|
|
215
215
|
Buffer.from([0x00, 0x01, 0x07, 0xff, 0xfe, 0x00]),
|
|
216
216
|
]);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
]
|
|
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
|
+
);
|
|
221
231
|
|
|
222
232
|
function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}) {
|
|
223
233
|
return {
|
|
@@ -293,18 +303,18 @@ test("relay contract constants are the compiled gentle-ai handshake values", ()
|
|
|
293
303
|
// Happy path
|
|
294
304
|
// ---------------------------------------------------------------------------
|
|
295
305
|
|
|
296
|
-
test("relay happy path moves prompt and
|
|
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) => {
|
|
297
307
|
const fixture = harness(t);
|
|
298
308
|
const result = await runReviewHostRelaySlot(relayRequest(fixture));
|
|
299
309
|
|
|
300
310
|
assert.equal(result.promptByteLength, PROMPT_BYTES.length);
|
|
301
|
-
assert.equal(result.resultByteLength,
|
|
311
|
+
assert.equal(result.resultByteLength, Buffer.byteLength(PI_REVIEWER_TEXT));
|
|
302
312
|
assert.equal(JSON.parse(result.submission).admission_decision, "completed");
|
|
303
313
|
|
|
304
314
|
// Prompt bytes reached pi stdin verbatim.
|
|
305
315
|
assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
|
|
306
|
-
// Submission --input file bytes are EXACTLY the
|
|
307
|
-
assert.deepEqual(readFileSync(fixture.submitCapturePath),
|
|
316
|
+
// Submission --input file bytes are EXACTLY the reviewer's assistant text.
|
|
317
|
+
assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(PI_REVIEWER_TEXT, "utf8"));
|
|
308
318
|
|
|
309
319
|
const gentleAiCalls = readLog(fixture.logPath);
|
|
310
320
|
assert.equal(gentleAiCalls.length, 2);
|
|
@@ -335,13 +345,13 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
|
|
|
335
345
|
assert.equal(existsSync(piCalls[0]!.cwd!), false);
|
|
336
346
|
});
|
|
337
347
|
|
|
338
|
-
test("the pi lockdown argv is pinned exactly with no model or provider selection", async (t) => {
|
|
348
|
+
test("the pi lockdown argv is pinned exactly with no default model or provider selection", async (t) => {
|
|
339
349
|
const fixture = harness(t);
|
|
340
350
|
await runReviewHostRelaySlot(relayRequest(fixture));
|
|
341
351
|
const piCalls = readLog(fixture.piLogPath);
|
|
342
352
|
const expected = [
|
|
343
353
|
"--print",
|
|
344
|
-
"--mode", "
|
|
354
|
+
"--mode", "json",
|
|
345
355
|
"--no-session",
|
|
346
356
|
"--no-tools",
|
|
347
357
|
"--no-extensions",
|
|
@@ -353,6 +363,8 @@ test("the pi lockdown argv is pinned exactly with no model or provider selection
|
|
|
353
363
|
];
|
|
354
364
|
assert.deepEqual([...REVIEW_HOST_RELAY_PI_ARGV], expected);
|
|
355
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.
|
|
356
368
|
assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
|
|
357
369
|
});
|
|
358
370
|
|
|
@@ -382,7 +394,7 @@ test("preparation snapshots mutable submission tokens and values before material
|
|
|
382
394
|
const result = await prepared;
|
|
383
395
|
assert.deepEqual(result.request.submission, SUBMISSION);
|
|
384
396
|
await submitReviewHostRelayPreparedResult(result);
|
|
385
|
-
assert.deepEqual(readFileSync(fixture.submitCapturePath),
|
|
397
|
+
assert.deepEqual(readFileSync(fixture.submitCapturePath), Buffer.from(PI_REVIEWER_TEXT, "utf8"));
|
|
386
398
|
});
|
|
387
399
|
|
|
388
400
|
test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
|
|
@@ -430,7 +442,7 @@ test("four reviewers cross a shared barrier before any result submission can beg
|
|
|
430
442
|
const prepared = await runReviewHostRelayReviewerGroup(requests);
|
|
431
443
|
|
|
432
444
|
assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
|
|
433
|
-
assert.ok(prepared.every((result) => result.resultByteLength ===
|
|
445
|
+
assert.ok(prepared.every((result) => result.resultByteLength === Buffer.byteLength(PI_REVIEWER_TEXT)));
|
|
434
446
|
assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
|
|
435
447
|
assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
|
|
436
448
|
});
|
|
@@ -527,9 +539,65 @@ test("pi nonzero exit fails closed with a typed error and no submission", async
|
|
|
527
539
|
|
|
528
540
|
test("empty pi stdout fails closed with a typed error and no submission", async (t) => {
|
|
529
541
|
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "empty" });
|
|
530
|
-
await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
|
|
542
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
|
|
531
543
|
assert.equal(readLog(fixture.logPath).length, 1);
|
|
532
544
|
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/);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("a reviewer run that only attempted a tool call fails typed with the child's own evidence", async (t) => {
|
|
551
|
+
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");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("the relay forwards the caller-owned reviewer selection and extension allowlist to the child argv", async (t) => {
|
|
568
|
+
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);
|
|
576
|
+
assert.equal(calls.length, 1);
|
|
577
|
+
assert.deepEqual(calls[0]!.argv.slice(-4), ["--model", "minimax/MiniMax-M3", "-e", adapterPath]);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("a malformed reviewer selection or a relative extension path is refused typed before anything launches", async (t) => {
|
|
581
|
+
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
|
+
}
|
|
533
601
|
});
|
|
534
602
|
|
|
535
603
|
test("pi timeout fails closed with a typed error and no submission", async (t) => {
|
|
@@ -653,7 +721,8 @@ function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
|
|
|
653
721
|
environment: {
|
|
654
722
|
...fixture.environment,
|
|
655
723
|
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
656
|
-
|
|
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"),
|
|
657
726
|
},
|
|
658
727
|
});
|
|
659
728
|
}
|