gentle-pi 3.2.0 → 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 +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +15 -5
- package/docs/readme-reference.md +6 -6
- package/docs/review-integration.md +15 -6
- package/extensions/gentle-ai.ts +72 -7
- package/extensions/gentle-shell.ts +29 -7
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +9 -0
- package/lib/opaque-pi-reviewer-adapter.ts +130 -10
- 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 +9 -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/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 +14 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +153 -9
- package/tests/package-manifest.test.ts +6 -6
- 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
|
@@ -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
|
}
|
|
@@ -2,9 +2,10 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { delimiter as pathDelimiter, join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
8
|
+
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError } from "../lib/review-host-relay.ts";
|
|
8
9
|
import { NativeReviewIntegrationError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
9
10
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
10
11
|
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
@@ -186,6 +187,90 @@ test("the negotiated status asks for the pi agent so the provider offers its mat
|
|
|
186
187
|
assert.equal(hostRelay.transport, "pi_host_relay");
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
// gentle-shell#1136 / #1158: the lens's user-owned reviewer selection (agent
|
|
191
|
+
// model routing config) and the extension allowlist environment ride the relay
|
|
192
|
+
// request; without them the child runs the ambient default with no auth
|
|
193
|
+
// adapters. With neither configured the launch stays selection-free.
|
|
194
|
+
test("capture forwards the lens's user-owned reviewer selection and extension allowlist to the relay request", async (t) => {
|
|
195
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
196
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-"));
|
|
197
|
+
const cwd = repository(t);
|
|
198
|
+
t.after(() => rmSync(configHome, { recursive: true, force: true }));
|
|
199
|
+
writeFileSync(join(configHome, "models.json"), JSON.stringify({ "review-reliability": { model: "minimax/MiniMax-M3" } }), "utf8");
|
|
200
|
+
const adapter = join(configHome, "auth-adapter.ts");
|
|
201
|
+
const second = join(configHome, "second-adapter.ts");
|
|
202
|
+
writeFileSync(adapter, "export default () => {};");
|
|
203
|
+
writeFileSync(second, "export default () => {};");
|
|
204
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
205
|
+
const previousExtensions = process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
|
|
206
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
207
|
+
process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = [adapter, second].join(pathDelimiter);
|
|
208
|
+
t.after(() => {
|
|
209
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
210
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
211
|
+
if (previousExtensions === undefined) delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
|
|
212
|
+
else process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = previousExtensions;
|
|
213
|
+
});
|
|
214
|
+
const { native } = transportAwareNative();
|
|
215
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
216
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
217
|
+
relayed.push(request);
|
|
218
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await runCapture(cwd, native, "selection-lineage");
|
|
222
|
+
assert.equal(relayed.length, 1);
|
|
223
|
+
assert.equal(relayed[0]!.reviewerModel, "minimax/MiniMax-M3", "the lens's routing entry must name the child's selection");
|
|
224
|
+
assert.deepEqual(relayed[0]!.reviewerExtensionPaths, [adapter, second]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("capture keeps the relay launch selection-free when the user configured neither a selection nor extensions", async (t) => {
|
|
228
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
229
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-relay-config-empty-"));
|
|
230
|
+
const cwd = repository(t);
|
|
231
|
+
t.after(() => rmSync(configHome, { recursive: true, force: true }));
|
|
232
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
233
|
+
const previousExtensions = process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
|
|
234
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
235
|
+
delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
|
|
236
|
+
t.after(() => {
|
|
237
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
238
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
239
|
+
if (previousExtensions === undefined) delete process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS;
|
|
240
|
+
else process.env.GENTLE_PI_REVIEW_RELAY_EXTENSIONS = previousExtensions;
|
|
241
|
+
});
|
|
242
|
+
const { native } = transportAwareNative();
|
|
243
|
+
const relayed: ReviewHostRelayRequest[] = [];
|
|
244
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
|
|
245
|
+
relayed.push(request);
|
|
246
|
+
return { promptByteLength: 128, resultByteLength: 64, submission: '{"admission_decision":"completed"}' };
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await runCapture(cwd, native, "default-lineage");
|
|
250
|
+
assert.equal(relayed.length, 1);
|
|
251
|
+
assert.equal(relayed[0]!.reviewerModel, undefined);
|
|
252
|
+
assert.equal(relayed[0]!.reviewerExtensionPaths, undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a relayed empty-output failure carries the child's own evidence in the failure report", async (t) => {
|
|
256
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
257
|
+
const cwd = repository(t);
|
|
258
|
+
const { native } = transportAwareNative();
|
|
259
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
260
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no assistant text (stdout kind: no-assistant-text; a tool call was attempted)", {
|
|
261
|
+
reviewerEvidence: { stdoutKind: "no-assistant-text", reviewerModel: "nan/deepseek-v4-flash", toolCallAttempted: true },
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const result = await runCapture(cwd, native, "evidence-lineage");
|
|
266
|
+
assert.equal(result.outcome, "pi-host-relay-transport-failure");
|
|
267
|
+
const failure = result.failure as { reviewer?: { stdoutKind?: string; reviewerModel?: string; toolCallAttempted?: boolean } } | undefined;
|
|
268
|
+
assert.ok(failure !== undefined, "the envelope carries the failure report");
|
|
269
|
+
assert.equal(failure.reviewer?.stdoutKind, "no-assistant-text");
|
|
270
|
+
assert.equal(failure.reviewer?.reviewerModel, "nan/deepseek-v4-flash");
|
|
271
|
+
assert.equal(failure.reviewer?.toolCallAttempted, true);
|
|
272
|
+
});
|
|
273
|
+
|
|
189
274
|
test("capture forecasts the reviewer model run once and spends nothing until it is acknowledged", async (t) => {
|
|
190
275
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
191
276
|
const cwd = repository(t);
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ShellBarModel,
|
|
13
13
|
type ShellBarTheme,
|
|
14
14
|
} from "../lib/shell-bar.ts";
|
|
15
|
+
import { parseNanQuota } from "../lib/shell-usage.ts";
|
|
15
16
|
|
|
16
17
|
// The Gentle Shell bar replaces pi's three-line footer with one line of
|
|
17
18
|
// segments. Rendering is pure so it can be verified without a TUI.
|
|
@@ -52,6 +53,28 @@ function model(overrides: Partial<ShellBarModel> = {}): ShellBarModel {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// The grouped NaN fixture the panel test uses, so both surfaces are asserted
|
|
57
|
+
// against the same payload, the same order and the same percentages.
|
|
58
|
+
const GROUPED_NAN_QUOTA = {
|
|
59
|
+
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
60
|
+
models: [
|
|
61
|
+
{ model: "glm5.3-flash", cap: 2_000_000_000, tokensUsed: 200_000_000 },
|
|
62
|
+
{ model: "glm5.3", cap: 3_000_000_000, tokensUsed: 0, periodEnd: "2026-10-17T05:53:20.000Z" },
|
|
63
|
+
{ model: "glm5.2", cap: 3_000_000_000, tokensUsed: 0, periodEnd: "2026-10-17T05:53:20.000Z" },
|
|
64
|
+
{ model: "deepseek-v4-flash", cap: 3_000_000_000, tokensUsed: 300_000_000 },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// The sidebar prints the card body between its borders; the Usage rows are the
|
|
69
|
+
// metered block between the Usage heading and Integrations, minus the context
|
|
70
|
+
// meter, which is not a subscription allowance.
|
|
71
|
+
function sidebarUsageRows(lines: string[]): string[] {
|
|
72
|
+
const body = lines.filter((line) => line.startsWith("│ ")).map((line) => line.slice(2, -2).trim());
|
|
73
|
+
const start = body.indexOf("Usage");
|
|
74
|
+
const end = body.indexOf("Integrations");
|
|
75
|
+
return body.slice(start + 1, end).filter((line) => /[▰▱]/.test(line) && !line.startsWith("Context"));
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
test("renderGauge fills cells proportionally to the percentage", () => {
|
|
56
79
|
assert.equal(renderGauge(45, 8), "▰▰▰▰▱▱▱▱");
|
|
57
80
|
assert.equal(renderGauge(0, 8), "▱▱▱▱▱▱▱▱");
|
|
@@ -81,13 +104,13 @@ test("renderShellBar renders one line with the segments in order", () => {
|
|
|
81
104
|
assert.equal(rest.length, 0);
|
|
82
105
|
assert.equal(
|
|
83
106
|
line,
|
|
84
|
-
"✿ gentle
|
|
107
|
+
"✿ gentle shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub",
|
|
85
108
|
);
|
|
86
109
|
});
|
|
87
110
|
|
|
88
111
|
test("renderShellBar colors the brand, model, effort, and gauge by role", () => {
|
|
89
112
|
const [line] = renderShellBar(model(), taggedTheme, 400);
|
|
90
|
-
assert.match(line, /<accent>✿ gentle
|
|
113
|
+
assert.match(line, /<accent>✿ gentle shell<\/accent>/);
|
|
91
114
|
assert.match(line, /<text>gpt-5\.5<\/text>/);
|
|
92
115
|
assert.match(line, /<syntaxFunction>medium<\/syntaxFunction>/);
|
|
93
116
|
assert.match(line, /<accent>▰▰▰▰<\/accent><border>▱▱▱▱<\/border>/);
|
|
@@ -120,6 +143,133 @@ test("renderShellBar adds the subscription windows after the cost when usage is
|
|
|
120
143
|
assert.match(line, /\$9\.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%$/);
|
|
121
144
|
});
|
|
122
145
|
|
|
146
|
+
test("renderShellBar meters the model the session is using inside a multi-model provider", () => {
|
|
147
|
+
const usage = {
|
|
148
|
+
provider: "nan",
|
|
149
|
+
plan: undefined,
|
|
150
|
+
fetchedAt: 0,
|
|
151
|
+
limits: [
|
|
152
|
+
{ name: "deepseek-v4-flash", limitReached: false, windows: [{ label: "", usedPercent: 18, windowSeconds: 0, resetAt: null, used: 545_000_000, budget: 3_000_000_000 }] },
|
|
153
|
+
{ name: "glm5.3-flash", limitReached: false, windows: [{ label: "", usedPercent: 10, windowSeconds: 0, resetAt: null, used: 200_000_000, budget: 2_000_000_000 }] },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
const [glm] = renderShellBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 200);
|
|
157
|
+
assert.match(glm, /glm5\.3-flash ▰▱▱▱▱▱▱▱ 10%$/);
|
|
158
|
+
assert.doesNotMatch(glm, /deepseek-v4-flash ▰/);
|
|
159
|
+
const [other] = renderShellBar(model({ modelId: "deepseek-v4-flash", usage }), plainTheme, 200);
|
|
160
|
+
assert.match(other, /deepseek-v4-flash ▰▱▱▱▱▱▱▱ 18%$/);
|
|
161
|
+
const sidebar = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60).join("\n");
|
|
162
|
+
assert.match(sidebar, /glm5\.3-flash +▰▱▱▱▱▱▱▱ +10%/, "the sidebar lists the session model's own allowance");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The sidebar is the surface that never needs opening, so a provider with
|
|
166
|
+
// per-model allowances shows the panel's model rows there too — most consumed
|
|
167
|
+
// family first, its models inside it — and leaves the aggregate totals and the
|
|
168
|
+
// reset dates to the bar and the panel.
|
|
169
|
+
test("sidebar groups the NaN allowances by subscription without totals or resets", () => {
|
|
170
|
+
const usage = parseNanQuota(GROUPED_NAN_QUOTA, 0);
|
|
171
|
+
const lines = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60);
|
|
172
|
+
const rows = sidebarUsageRows(lines);
|
|
173
|
+
assert.deepEqual(rows.map((row) => row.replace(/\s*[▰▱].*$/, "")), ["deepseek-v4-flash", "glm5.3-flash"]);
|
|
174
|
+
assert.deepEqual(rows.map((row) => Number.parseInt(row.match(/(\d+)%$/)![1] ?? "", 10)), [10, 10]);
|
|
175
|
+
assert.equal(rows.some((row) => / 0%$/.test(row)), false, "a window that consumed nothing only spends space");
|
|
176
|
+
assert.equal(lines.join("\n").includes("total"), false, "an aggregate row only costs space");
|
|
177
|
+
assert.equal(lines.join("\n").includes("resets in"), false, "the reset dates belong to the panel");
|
|
178
|
+
for (const width of [24, 46, 60]) {
|
|
179
|
+
assert.ok(renderShellSidebarBar(model({ usage }), plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("sidebar treats one metered NaN allowance as a per-model provider", () => {
|
|
184
|
+
const usage = parseNanQuota({
|
|
185
|
+
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
186
|
+
models: [{ model: "glm5.3-flash", cap: 2_000_000_000, tokensUsed: 400_000_000, windowHours: 4, windowTokens: 400_000_000, windowTokensUsed: 120_000_000, windowResetsAt: 1_788_620_161 }],
|
|
187
|
+
}, 0);
|
|
188
|
+
const lines = renderShellSidebarBar(model({ modelId: "glm5.3-flash", usage }), plainTheme, 60);
|
|
189
|
+
const rows = sidebarUsageRows(lines).map((row) => row.replace(/\s+/g, " "));
|
|
190
|
+
// A single allowance is still the panel's rows, not the bar's one-line meter:
|
|
191
|
+
// the rolling window it reports is a row of its own here too, and the reset
|
|
192
|
+
// stays in the panel.
|
|
193
|
+
assert.deepEqual(rows, ["glm5.3-flash ▰▰▱▱▱▱▱▱ 20%", "glm5.3-flash 4h ▰▰▱▱▱▱▱▱ 30%"]);
|
|
194
|
+
assert.equal(lines.join("\n").includes("resets in"), false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("sidebar keeps one aggregate line for a provider without raw allowances", () => {
|
|
198
|
+
const usage = {
|
|
199
|
+
provider: "openai-codex",
|
|
200
|
+
plan: "pro",
|
|
201
|
+
fetchedAt: 0,
|
|
202
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
203
|
+
{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null },
|
|
204
|
+
{ label: "week", usedPercent: 31, windowSeconds: 604_800, resetAt: null },
|
|
205
|
+
] }],
|
|
206
|
+
};
|
|
207
|
+
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
208
|
+
assert.deepEqual(sidebarUsageRows(lines), ["codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%"]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("sidebar drops an aggregate allowance that consumed nothing", () => {
|
|
212
|
+
const usage = {
|
|
213
|
+
provider: "openai-codex",
|
|
214
|
+
plan: "pro",
|
|
215
|
+
fetchedAt: 0,
|
|
216
|
+
limits: [{ name: "codex", limitReached: false, windows: [
|
|
217
|
+
{ label: "5h", usedPercent: 0, windowSeconds: 18_000, resetAt: null },
|
|
218
|
+
{ label: "week", usedPercent: 0, windowSeconds: 604_800, resetAt: null },
|
|
219
|
+
] }],
|
|
220
|
+
};
|
|
221
|
+
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
222
|
+
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
223
|
+
const text = lines.join("\n");
|
|
224
|
+
assert.match(text, /Usage/);
|
|
225
|
+
assert.match(text, /Context/);
|
|
226
|
+
assert.match(text, /Cost/);
|
|
227
|
+
// The bar keeps its own contract: only the sidebar gives zero rows the boot.
|
|
228
|
+
assert.match(renderShellBar(model({ usage }), plainTheme, 200)[0], /codex 5h ▱▱▱▱▱▱▱▱ 0% · week 0%$/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("sidebar keeps the Usage group when nothing was consumed", () => {
|
|
232
|
+
const usage = parseNanQuota({
|
|
233
|
+
periodEnd: "2026-10-01T00:00:00.000Z",
|
|
234
|
+
models: [
|
|
235
|
+
{ model: "glm5.3", cap: 3_000_000_000, tokensUsed: 0 },
|
|
236
|
+
{ model: "glm5.2", cap: 3_000_000_000, tokensUsed: 0 },
|
|
237
|
+
],
|
|
238
|
+
}, 0);
|
|
239
|
+
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
240
|
+
const text = lines.join("\n");
|
|
241
|
+
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
242
|
+
assert.match(text, /Usage/);
|
|
243
|
+
assert.match(text, /Context/);
|
|
244
|
+
assert.match(text, /Cost/);
|
|
245
|
+
assert.match(text, /Integrations/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("sidebar hides exactly the rows that would print 0%, rounding included", () => {
|
|
249
|
+
// The row's own rounding is the only threshold: a fraction below half a
|
|
250
|
+
// percent is a 0% row and leaves; half a percent keeps its row and prints 1%.
|
|
251
|
+
const usage = (usedPercent: number) => ({
|
|
252
|
+
provider: "openai-codex",
|
|
253
|
+
plan: "pro",
|
|
254
|
+
fetchedAt: 0,
|
|
255
|
+
limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent, windowSeconds: 18_000, resetAt: null }] }],
|
|
256
|
+
});
|
|
257
|
+
assert.deepEqual(sidebarUsageRows(renderShellSidebarBar(model({ usage: usage(0.4) }), plainTheme, 60)), []);
|
|
258
|
+
assert.deepEqual(sidebarUsageRows(renderShellSidebarBar(model({ usage: usage(0.5) }), plainTheme, 60)), ["codex 5h ▱▱▱▱▱▱▱▱ 1%"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("sidebar reads a limit without windows as nothing to draw, not as zero consumption", () => {
|
|
262
|
+
const usage = {
|
|
263
|
+
provider: "openai-codex",
|
|
264
|
+
plan: "pro",
|
|
265
|
+
fetchedAt: 0,
|
|
266
|
+
limits: [{ name: "codex", limitReached: false, windows: [] }],
|
|
267
|
+
};
|
|
268
|
+
const lines = renderShellSidebarBar(model({ usage }), plainTheme, 60);
|
|
269
|
+
assert.deepEqual(sidebarUsageRows(lines), []);
|
|
270
|
+
assert.match(lines.join("\n"), /Cost/);
|
|
271
|
+
});
|
|
272
|
+
|
|
123
273
|
test("renderShellBar shows an unknown context as a question mark after compaction", () => {
|
|
124
274
|
const [line] = renderShellBar(model({ contextPercent: null }), plainTheme, 160);
|
|
125
275
|
assert.match(line, /ctx ▱▱▱▱▱▱▱▱ \?%/);
|
|
@@ -162,7 +312,7 @@ test("renderShellBar drops the session name, then trailing segments, before trun
|
|
|
162
312
|
|
|
163
313
|
const [atFifty] = renderShellBar(wide, plainTheme, 50);
|
|
164
314
|
assert.ok(visibleWidth(atFifty) <= 50, `line overflowed: ${visibleWidth(atFifty)}`);
|
|
165
|
-
assert.match(atFifty, /^✿ gentle
|
|
315
|
+
assert.match(atFifty, /^✿ gentle shell/);
|
|
166
316
|
});
|
|
167
317
|
|
|
168
318
|
test("shellEnabled stays off inside a Gentle Agents child", () => {
|
|
@@ -34,7 +34,8 @@ test("UsageView frames the panel, keeps every line at width, and shows the empty
|
|
|
34
34
|
for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
|
|
35
35
|
const plain = lines.map(stripAnsi);
|
|
36
36
|
assert.match(plain[1], /^│ openai-codex · pro · updated just now +│$/);
|
|
37
|
-
assert.match(plain[
|
|
37
|
+
assert.match(plain[2], /^│ {3}codex week +[▰▱]{16} +40% · resets in 2h 0m +│$/);
|
|
38
|
+
assert.match(plain[3], /r refresh .* esc close/);
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
test("UsageView refetches on r and closes on escape or q", async () => {
|
|
@@ -54,7 +55,7 @@ test("UsageView refetches on r and closes on escape or q", async () => {
|
|
|
54
55
|
view.handleInput("r");
|
|
55
56
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
57
|
assert.deepEqual(events, ["render", "refresh", "render"]);
|
|
57
|
-
assert.match(stripAnsi(view.render(90)[
|
|
58
|
+
assert.match(stripAnsi(view.render(90)[2]), /55%/);
|
|
58
59
|
assert.match(stripAnsi(view.render(90)[1]), /^│ ✿ openai-codex · pro · updated just now/);
|
|
59
60
|
view.handleInput("\x1b");
|
|
60
61
|
view.handleInput("q");
|