gentle-pi 3.5.1 → 3.7.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/README.md +8 -1
- package/assets/orchestrator-delegation.md +2 -0
- package/bin/gentle-shell.mjs +900 -23
- package/docs/gentle-shell.md +5 -4
- package/docs/readme-reference.md +53 -39
- package/extensions/gentle-agents.ts +89 -22
- package/extensions/gentle-ai.ts +27 -48
- package/lib/agents-runner.ts +9 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-shell-launcher.ts +314 -2
- package/lib/inprocess-reviewer.ts +54 -6
- package/lib/native-review-cli.ts +16 -0
- package/lib/session-change-capture.ts +10 -1
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +313 -1
- package/runtime/native-review-cli.mjs +16 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-tui-mode-setting.mjs +21 -2
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +22 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/gentle-agents.test.ts +362 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +54 -49
- package/tests/gentle-ai.test.ts +147 -2
- package/tests/gentle-shell-bin.test.ts +1739 -4
- package/tests/gentle-shell-launcher.test.ts +394 -0
- package/tests/inprocess-reviewer.test.ts +179 -0
- package/tests/install-tui-mode-setting.test.ts +22 -4
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/odd-runtime-delegation-gate.test.ts +18 -197
- package/tests/package-manifest.test.ts +6 -6
- package/tests/runtime-harness.mjs +1 -2
- package/tests/session-change-capture.test.ts +12 -1
- package/lib/odd-runtime-delegation-gate.ts +0 -88
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import {
|
|
7
7
|
MIN_PI_VERSION,
|
|
8
|
+
MIN_SETUP_GENTLE_AI_VERSION,
|
|
8
9
|
PI_SUBCOMMANDS,
|
|
9
10
|
buildPiInvocation,
|
|
10
11
|
checkPeerVersionPin,
|
|
@@ -13,20 +14,31 @@ import {
|
|
|
13
14
|
describeVersion,
|
|
14
15
|
discoverLooseExtensionEntries,
|
|
15
16
|
findGentlePiDeclaration,
|
|
17
|
+
forceJsonFieldIfAbsentInOriginal,
|
|
16
18
|
helpText,
|
|
19
|
+
homeSelectorFlags,
|
|
20
|
+
isSetupCapablePin,
|
|
17
21
|
launcherConfigPath,
|
|
18
22
|
type LooseExtensionFsEntry,
|
|
19
23
|
missingPiMessage,
|
|
24
|
+
needsProvisioning,
|
|
20
25
|
otherPackageInjections,
|
|
21
26
|
parseLauncherArgs,
|
|
22
27
|
parseLauncherConfig,
|
|
28
|
+
parseRawLauncherConfig,
|
|
23
29
|
planSpawn,
|
|
30
|
+
postInstallRemovals,
|
|
31
|
+
provisionedEntry,
|
|
24
32
|
quoteForCmdExe,
|
|
33
|
+
recordProvisioned,
|
|
25
34
|
resolveHome,
|
|
26
35
|
resolvePiRuntime,
|
|
36
|
+
restoreJsonField,
|
|
27
37
|
settingsDeclareGentlePi,
|
|
38
|
+
shellQuote,
|
|
28
39
|
type PackageJsonPeerShape,
|
|
29
40
|
type ParsedLauncherArgs,
|
|
41
|
+
type RawLauncherConfig,
|
|
30
42
|
type ResolvedHome,
|
|
31
43
|
} from "../lib/gentle-shell-launcher.ts";
|
|
32
44
|
|
|
@@ -146,6 +158,47 @@ test("parseLauncherArgs treats home as a plain passthrough token when it is not
|
|
|
146
158
|
assert.deepEqual(parsed.passthrough, ["home"]);
|
|
147
159
|
});
|
|
148
160
|
|
|
161
|
+
// --- setup subcommand ----------------------------------------------------
|
|
162
|
+
//
|
|
163
|
+
// Unlike `home`, `setup` is not restricted to argv[0]: it accepts the home
|
|
164
|
+
// selectors (--link, --isolated, --home <dir>) ahead of it, same as any pi
|
|
165
|
+
// subcommand would, since setup provisions whichever home those selectors
|
|
166
|
+
// resolve to.
|
|
167
|
+
|
|
168
|
+
test("parseLauncherArgs recognises setup as argv[0] and captures the rest as commandArgs", () => {
|
|
169
|
+
const parsed = parseLauncherArgs(["setup"]);
|
|
170
|
+
assert.equal(parsed.command, "setup");
|
|
171
|
+
assert.deepEqual(parsed.commandArgs, []);
|
|
172
|
+
assert.deepEqual(parsed.passthrough, []);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("parseLauncherArgs recognises setup after a home selector and keeps the selector", () => {
|
|
176
|
+
const parsed = parseLauncherArgs(["--isolated", "setup"]);
|
|
177
|
+
assert.equal(parsed.command, "setup");
|
|
178
|
+
assert.equal(parsed.isolated, true);
|
|
179
|
+
assert.deepEqual(parsed.commandArgs, []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("parseLauncherArgs recognises setup after --home <dir> and forwards --dry-run as commandArgs", () => {
|
|
183
|
+
const parsed = parseLauncherArgs(["--home", "/custom/path", "setup", "--dry-run"]);
|
|
184
|
+
assert.equal(parsed.command, "setup");
|
|
185
|
+
assert.equal(parsed.home, "/custom/path");
|
|
186
|
+
assert.deepEqual(parsed.commandArgs, ["--dry-run"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("parseLauncherArgs does not set piSubcommand for the setup launcher subcommand", () => {
|
|
190
|
+
const parsed = parseLauncherArgs(["setup"]);
|
|
191
|
+
assert.equal(parsed.command, "setup");
|
|
192
|
+
assert.equal(parsed.piSubcommand, undefined);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("parseLauncherArgs treats setup as a plain passthrough token once a pi subcommand already started", () => {
|
|
196
|
+
const parsed = parseLauncherArgs(["install", "setup"]);
|
|
197
|
+
assert.equal(parsed.command, undefined);
|
|
198
|
+
assert.equal(parsed.piSubcommand, "install");
|
|
199
|
+
assert.deepEqual(parsed.passthrough, ["install", "setup"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
149
202
|
// --- pi subcommand passthrough (install/remove/uninstall/update/list/config/auth) ---
|
|
150
203
|
|
|
151
204
|
test("parseLauncherArgs recognises install as a pi subcommand and keeps it in passthrough", () => {
|
|
@@ -301,6 +354,20 @@ test("resolveHome lets a flag override a persisted config", () => {
|
|
|
301
354
|
assert.equal(resolved.source, "flag");
|
|
302
355
|
});
|
|
303
356
|
|
|
357
|
+
// --- homeSelectorFlags -----------------------------------------------------
|
|
358
|
+
|
|
359
|
+
test("homeSelectorFlags reproduces --link for a link home", () => {
|
|
360
|
+
assert.deepEqual(homeSelectorFlags({ mode: "link", dir: "/home/alan/.pi/agent", source: "flag" }), ["--link"]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("homeSelectorFlags reproduces --home <dir> for a path home", () => {
|
|
364
|
+
assert.deepEqual(homeSelectorFlags({ mode: "path", dir: "/explicit/path", source: "flag" }), ["--home", "/explicit/path"]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("homeSelectorFlags is empty for the isolated default (no flags needed)", () => {
|
|
368
|
+
assert.deepEqual(homeSelectorFlags({ mode: "isolated", dir: "/home/alan/.gentle-shell/agent", source: "default" }), []);
|
|
369
|
+
});
|
|
370
|
+
|
|
304
371
|
// --- launcherConfigPath / parseLauncherConfig -----------------------------
|
|
305
372
|
|
|
306
373
|
test("launcherConfigPath points at <homedir>/.gentle-shell/config.json", () => {
|
|
@@ -344,6 +411,98 @@ test("parseLauncherConfig tolerates an empty home value", () => {
|
|
|
344
411
|
assert.equal(parseLauncherConfig('{"home":""}'), undefined);
|
|
345
412
|
});
|
|
346
413
|
|
|
414
|
+
// --- parseRawLauncherConfig / provisionedEntry / needsProvisioning / recordProvisioned (S7) ---
|
|
415
|
+
//
|
|
416
|
+
// Unlike parseLauncherConfig's discriminated LauncherConfig (home mode only),
|
|
417
|
+
// these operate on the full raw config.json object so a write never drops a
|
|
418
|
+
// key (like a sibling home's provisioned marker) it does not itself
|
|
419
|
+
// understand.
|
|
420
|
+
|
|
421
|
+
test("parseRawLauncherConfig returns an empty object for a missing file", () => {
|
|
422
|
+
assert.deepEqual(parseRawLauncherConfig(undefined), {});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("parseRawLauncherConfig tolerates invalid JSON and non-object documents", () => {
|
|
426
|
+
assert.deepEqual(parseRawLauncherConfig("not json"), {});
|
|
427
|
+
assert.deepEqual(parseRawLauncherConfig("[]"), {});
|
|
428
|
+
assert.deepEqual(parseRawLauncherConfig('"link"'), {});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("parseRawLauncherConfig preserves every key, not just home", () => {
|
|
432
|
+
assert.deepEqual(parseRawLauncherConfig('{"home":"link","provisioned":{"/a":{"gentleAi":"3.6.0","at":"2026-09-22T00:00:00.000Z"}}}'), {
|
|
433
|
+
home: "link",
|
|
434
|
+
provisioned: { "/a": { gentleAi: "3.6.0", at: "2026-09-22T00:00:00.000Z" } },
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("provisionedEntry is undefined for a home with no marker", () => {
|
|
439
|
+
assert.equal(provisionedEntry({}, "/home/alan/.gentle-shell/agent"), undefined);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("provisionedEntry returns the stored record for a matching home", () => {
|
|
443
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", at: "2026-09-22T00:00:00.000Z" } } };
|
|
444
|
+
assert.deepEqual(provisionedEntry(config, "/a"), { gentleAi: "3.6.0", at: "2026-09-22T00:00:00.000Z" });
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("provisionedEntry tolerates a malformed provisioned map (non-object, missing fields)", () => {
|
|
448
|
+
assert.equal(provisionedEntry({ provisioned: "nope" } as unknown as RawLauncherConfig, "/a"), undefined);
|
|
449
|
+
assert.equal(provisionedEntry({ provisioned: { "/a": { gentleAi: "3.6.0" } } } as unknown as RawLauncherConfig, "/a"), undefined);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("needsProvisioning is true for a home with no marker", () => {
|
|
453
|
+
assert.equal(needsProvisioning({}, "/a", "3.6.0", "3.5.1"), true);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("needsProvisioning is true when the marker's pin differs from the current pin", () => {
|
|
457
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", gentlePi: "3.5.1", at: "2026-09-22T00:00:00.000Z" } } };
|
|
458
|
+
assert.equal(needsProvisioning(config, "/a", "3.6.1", "3.5.1"), true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("needsProvisioning is false when the marker's pin and gentle-pi version both match the current ones", () => {
|
|
462
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", gentlePi: "3.5.1", at: "2026-09-22T00:00:00.000Z" } } };
|
|
463
|
+
assert.equal(needsProvisioning(config, "/a", "3.6.0", "3.5.1"), false);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// A marker written before gentle-pi version tracking existed (S8) has no
|
|
467
|
+
// `gentlePi` field at all; that omission must never equal a real running
|
|
468
|
+
// version, so it always counts as needing provisioning even though the
|
|
469
|
+
// gentle-ai pin itself still matches.
|
|
470
|
+
test("needsProvisioning is true when the marker predates gentle-pi version tracking (no gentlePi field)", () => {
|
|
471
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", at: "2026-09-22T00:00:00.000Z" } } };
|
|
472
|
+
assert.equal(needsProvisioning(config, "/a", "3.6.0", "3.5.1"), true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("needsProvisioning is true when the marker's gentle-pi version differs from the running one, even though the pin matches", () => {
|
|
476
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", gentlePi: "3.5.0", at: "2026-09-22T00:00:00.000Z" } } };
|
|
477
|
+
assert.equal(needsProvisioning(config, "/a", "3.6.0", "3.5.1"), true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("recordProvisioned adds a marker (gentleAi, gentlePi, at) and preserves every other key, including other homes", () => {
|
|
481
|
+
const config: RawLauncherConfig = {
|
|
482
|
+
home: "isolated",
|
|
483
|
+
provisioned: { "/other": { gentleAi: "3.5.0", gentlePi: "3.4.0", at: "2026-01-01T00:00:00.000Z" } },
|
|
484
|
+
};
|
|
485
|
+
const updated = recordProvisioned(config, "/a", "3.6.0", "3.5.1", "2026-09-22T00:00:00.000Z");
|
|
486
|
+
assert.deepEqual(updated, {
|
|
487
|
+
home: "isolated",
|
|
488
|
+
provisioned: {
|
|
489
|
+
"/other": { gentleAi: "3.5.0", gentlePi: "3.4.0", at: "2026-01-01T00:00:00.000Z" },
|
|
490
|
+
"/a": { gentleAi: "3.6.0", gentlePi: "3.5.1", at: "2026-09-22T00:00:00.000Z" },
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
// Returns a new object; never mutates the input.
|
|
494
|
+
assert.deepEqual(config, {
|
|
495
|
+
home: "isolated",
|
|
496
|
+
provisioned: { "/other": { gentleAi: "3.5.0", gentlePi: "3.4.0", at: "2026-01-01T00:00:00.000Z" } },
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("recordProvisioned overwrites an existing marker for the same home", () => {
|
|
501
|
+
const config: RawLauncherConfig = { provisioned: { "/a": { gentleAi: "3.6.0", gentlePi: "3.5.0", at: "2026-01-01T00:00:00.000Z" } } };
|
|
502
|
+
const updated = recordProvisioned(config, "/a", "3.6.1", "3.5.1", "2026-09-22T00:00:00.000Z");
|
|
503
|
+
assert.deepEqual(updated, { provisioned: { "/a": { gentleAi: "3.6.1", gentlePi: "3.5.1", at: "2026-09-22T00:00:00.000Z" } } });
|
|
504
|
+
});
|
|
505
|
+
|
|
347
506
|
// --- resolvePiRuntime ------------------------------------------------------
|
|
348
507
|
|
|
349
508
|
test("resolvePiRuntime prefers GENTLE_SHELL_PI over every other source", () => {
|
|
@@ -506,6 +665,44 @@ test("checkPiVersion accepts a custom minimum", () => {
|
|
|
506
665
|
assert.equal(checkPiVersion("1.2.0", "1.1.0").ok, true);
|
|
507
666
|
});
|
|
508
667
|
|
|
668
|
+
// --- isSetupCapablePin -------------------------------------------------------
|
|
669
|
+
// `gentle-shell setup` provisions a home through the package-local pinned
|
|
670
|
+
// gentle-ai binary by pointing PI_CODING_AGENT_DIR at that home; only
|
|
671
|
+
// gentle-ai >= 3.6.0 honors that variable in its own `install --agent pi`
|
|
672
|
+
// provisioning. An older pin would silently provision the caller's real
|
|
673
|
+
// ~/.pi/agent instead, so setup must refuse to spawn it.
|
|
674
|
+
|
|
675
|
+
test("MIN_SETUP_GENTLE_AI_VERSION is 3.6.0", () => {
|
|
676
|
+
assert.equal(MIN_SETUP_GENTLE_AI_VERSION, "3.6.0");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("isSetupCapablePin accepts a version equal to the minimum", () => {
|
|
680
|
+
assert.equal(isSetupCapablePin("3.6.0"), true);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("isSetupCapablePin accepts a version above the minimum", () => {
|
|
684
|
+
assert.equal(isSetupCapablePin("3.6.1"), true);
|
|
685
|
+
assert.equal(isSetupCapablePin("4.0.0"), true);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("isSetupCapablePin rejects a version below the minimum", () => {
|
|
689
|
+
assert.equal(isSetupCapablePin("3.5.1"), false);
|
|
690
|
+
assert.equal(isSetupCapablePin("3.5.9"), false);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("isSetupCapablePin accepts a v-prefixed version", () => {
|
|
694
|
+
assert.equal(isSetupCapablePin("v3.6.0"), true);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("isSetupCapablePin rejects unparsable input", () => {
|
|
698
|
+
assert.equal(isSetupCapablePin("not a version"), false);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("isSetupCapablePin accepts a custom minimum", () => {
|
|
702
|
+
assert.equal(isSetupCapablePin("2.0.0", "2.1.0"), false);
|
|
703
|
+
assert.equal(isSetupCapablePin("2.1.0", "2.1.0"), true);
|
|
704
|
+
});
|
|
705
|
+
|
|
509
706
|
// --- settingsDeclareGentlePi ------------------------------------------------
|
|
510
707
|
|
|
511
708
|
test("settingsDeclareGentlePi is false when settings text is undefined", () => {
|
|
@@ -547,6 +744,61 @@ test("settingsDeclareGentlePi is false for a path package entry, even one that r
|
|
|
547
744
|
assert.equal(settingsDeclareGentlePi('{"packages":["../../work/gentle-pi"]}'), false);
|
|
548
745
|
});
|
|
549
746
|
|
|
747
|
+
// --- postInstallRemovals -----------------------------------------------
|
|
748
|
+
//
|
|
749
|
+
// gentle-ai's managed Pi stack (gentle-ai #4820, gentle-shell #1277) still
|
|
750
|
+
// installs npm:@juicesharp/rpiv-ask-user-question, which conflicts with
|
|
751
|
+
// gentle-pi's own first-party ask_user_question tool: Pi refuses two
|
|
752
|
+
// providers for the same tool name. It also always declares npm:gentle-pi
|
|
753
|
+
// itself, which must never stay in the home's settings.json: this launcher
|
|
754
|
+
// always loads its own gentle-pi, so leaving that declaration in place would
|
|
755
|
+
// let the home drift onto whatever gentle-pi npm installed instead. This
|
|
756
|
+
// pure helper tells `gentle-shell setup` which declared packages it must
|
|
757
|
+
// remove after provisioning a home, for either reason.
|
|
758
|
+
|
|
759
|
+
test("postInstallRemovals is empty when settings text is undefined", () => {
|
|
760
|
+
assert.deepEqual(postInstallRemovals(undefined), []);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("postInstallRemovals is empty for invalid JSON", () => {
|
|
764
|
+
assert.deepEqual(postInstallRemovals("not json"), []);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("postInstallRemovals is empty when packages is absent", () => {
|
|
768
|
+
assert.deepEqual(postInstallRemovals("{}"), []);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("postInstallRemovals detects a bare npm:@juicesharp/rpiv-ask-user-question string entry", () => {
|
|
772
|
+
assert.deepEqual(postInstallRemovals('{"packages":["npm:@juicesharp/rpiv-ask-user-question"]}'), ["npm:@juicesharp/rpiv-ask-user-question"]);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("postInstallRemovals detects a versioned entry and returns the canonical unversioned source", () => {
|
|
776
|
+
assert.deepEqual(postInstallRemovals('{"packages":["npm:@juicesharp/rpiv-ask-user-question@1.2.3"]}'), [
|
|
777
|
+
"npm:@juicesharp/rpiv-ask-user-question",
|
|
778
|
+
]);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("postInstallRemovals detects a versioned object source entry", () => {
|
|
782
|
+
assert.deepEqual(postInstallRemovals('{"packages":[{"source":"npm:@juicesharp/rpiv-ask-user-question@1.2.3"}]}'), [
|
|
783
|
+
"npm:@juicesharp/rpiv-ask-user-question",
|
|
784
|
+
]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("postInstallRemovals detects npm:gentle-pi at any version, alongside an unrelated package", () => {
|
|
788
|
+
assert.deepEqual(postInstallRemovals('{"packages":["npm:gentle-pi@3.5.1","npm:some-other-package"]}'), ["npm:gentle-pi"]);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("postInstallRemovals dedupes a duplicated declaration and preserves declaration order", () => {
|
|
792
|
+
const settingsText =
|
|
793
|
+
'{"packages":["npm:gentle-pi@1.0.0","npm:@juicesharp/rpiv-ask-user-question@1.0.0","npm:@juicesharp/rpiv-ask-user-question@2.0.0"]}';
|
|
794
|
+
assert.deepEqual(postInstallRemovals(settingsText), ["npm:gentle-pi", "npm:@juicesharp/rpiv-ask-user-question"]);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("postInstallRemovals ignores a path entry that happens to share the package name", () => {
|
|
798
|
+
assert.deepEqual(postInstallRemovals('{"packages":["./local-ask-user-question"]}'), []);
|
|
799
|
+
assert.deepEqual(postInstallRemovals('{"packages":["./local-gentle-pi"]}'), []);
|
|
800
|
+
});
|
|
801
|
+
|
|
550
802
|
// --- findGentlePiDeclaration -------------------------------------------------
|
|
551
803
|
|
|
552
804
|
function readPackageNameStub(names: Record<string, string | undefined>) {
|
|
@@ -1331,6 +1583,142 @@ test("quoteForCmdExe quotes an empty token", () => {
|
|
|
1331
1583
|
assert.equal(quoteForCmdExe(""), '""');
|
|
1332
1584
|
});
|
|
1333
1585
|
|
|
1586
|
+
// --- restoreJsonField ---------------------------------------------------------
|
|
1587
|
+
//
|
|
1588
|
+
// Pure JSON merge used by bin/gentle-shell.mjs's setup flow to restore a
|
|
1589
|
+
// single field of `~/.gentle-ai/state.json` (managed_asset_digest) after the
|
|
1590
|
+
// pinned gentle-ai spawn rewrites it, the same way the whole-file
|
|
1591
|
+
// snapshot/restore already protects `~/.pi/gentle-ai/persona.json` — but
|
|
1592
|
+
// scoped to one field, since state.json also carries fields the pinned
|
|
1593
|
+
// gentle-ai is supposed to update (gentle-shell #<managed-asset-digest>).
|
|
1594
|
+
|
|
1595
|
+
test("restoreJsonField restores a changed field and keeps every other field untouched", () => {
|
|
1596
|
+
const original = `${JSON.stringify({ managed_asset_digest: "abc123", installed_agents: ["pi"] }, null, 2)}\n`;
|
|
1597
|
+
const current = `${JSON.stringify({ managed_asset_digest: "def456", installed_agents: ["pi", "claude"] }, null, 2)}\n`;
|
|
1598
|
+
const result = restoreJsonField(original, current, "managed_asset_digest");
|
|
1599
|
+
assert.equal(result, `${JSON.stringify({ managed_asset_digest: "abc123", installed_agents: ["pi", "claude"] }, null, 2)}\n`);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("restoreJsonField deletes the field when it was absent before", () => {
|
|
1603
|
+
const original = `${JSON.stringify({ installed_agents: ["pi"] }, null, 2)}\n`;
|
|
1604
|
+
const current = `${JSON.stringify({ managed_asset_digest: "def456", installed_agents: ["pi"] }, null, 2)}\n`;
|
|
1605
|
+
const result = restoreJsonField(original, current, "managed_asset_digest");
|
|
1606
|
+
assert.equal(result, `${JSON.stringify({ installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
test("restoreJsonField returns undefined when the field is unchanged", () => {
|
|
1610
|
+
const original = `${JSON.stringify({ managed_asset_digest: "abc123", installed_agents: ["pi"] }, null, 2)}\n`;
|
|
1611
|
+
const current = `${JSON.stringify({ managed_asset_digest: "abc123", installed_agents: ["pi", "claude"] }, null, 2)}\n`;
|
|
1612
|
+
assert.equal(restoreJsonField(original, current, "managed_asset_digest"), undefined);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("restoreJsonField returns undefined when the field stays absent on both sides", () => {
|
|
1616
|
+
const original = `${JSON.stringify({ installed_agents: ["pi"] }, null, 2)}\n`;
|
|
1617
|
+
const current = `${JSON.stringify({ installed_agents: ["pi", "claude"] }, null, 2)}\n`;
|
|
1618
|
+
assert.equal(restoreJsonField(original, current, "managed_asset_digest"), undefined);
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
test("restoreJsonField returns undefined for invalid original JSON", () => {
|
|
1622
|
+
const result = restoreJsonField("not json", '{"managed_asset_digest":"def456"}', "managed_asset_digest");
|
|
1623
|
+
assert.equal(result, undefined);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
test("restoreJsonField returns undefined for invalid current JSON", () => {
|
|
1627
|
+
const result = restoreJsonField('{"managed_asset_digest":"abc123"}', "not json", "managed_asset_digest");
|
|
1628
|
+
assert.equal(result, undefined);
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test("restoreJsonField preserves 2-space indentation and a trailing newline detected from the original text", () => {
|
|
1632
|
+
const original = '{\n "managed_asset_digest": "abc123"\n}\n';
|
|
1633
|
+
const current = '{"managed_asset_digest":"def456","installed_agents":["pi"]}';
|
|
1634
|
+
const result = restoreJsonField(original, current, "managed_asset_digest");
|
|
1635
|
+
assert.equal(result, `${JSON.stringify({ managed_asset_digest: "abc123", installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
test("restoreJsonField matches compact formatting (no indent, no trailing newline) when the original had none", () => {
|
|
1639
|
+
const original = '{"managed_asset_digest":"abc123"}';
|
|
1640
|
+
const current = '{"managed_asset_digest":"def456","installed_agents":["pi"]}';
|
|
1641
|
+
const result = restoreJsonField(original, current, "managed_asset_digest");
|
|
1642
|
+
assert.equal(result, '{"managed_asset_digest":"abc123","installed_agents":["pi"]}');
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// --- forceJsonFieldIfAbsentInOriginal ------------------------------------------
|
|
1646
|
+
//
|
|
1647
|
+
// Pure JSON merge used by bin/gentle-shell.mjs's setup flow to make sure a
|
|
1648
|
+
// home gentle-shell provisions ends up with the default Gentle Shell theme
|
|
1649
|
+
// unless the home (or the user) already had an opinion about it — even when
|
|
1650
|
+
// gentle-ai's own managed install wrote a *different* default theme into
|
|
1651
|
+
// settings.json. Unlike restoreJsonField above (which restores a field back
|
|
1652
|
+
// to whatever it was originally), this only ever forces one specific value,
|
|
1653
|
+
// and only when the original text had no opinion on the field at all.
|
|
1654
|
+
|
|
1655
|
+
test("forceJsonFieldIfAbsentInOriginal forces the field when the original had none and the current text disagrees", () => {
|
|
1656
|
+
const original = `${JSON.stringify({ tuiMode: "fullscreen" }, null, 2)}\n`;
|
|
1657
|
+
const current = `${JSON.stringify({ tuiMode: "fullscreen", theme: "kanagawa" }, null, 2)}\n`;
|
|
1658
|
+
const result = forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute");
|
|
1659
|
+
assert.equal(result, `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute" }, null, 2)}\n`);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
test("forceJsonFieldIfAbsentInOriginal forces the field when the original had none and the current text also has none", () => {
|
|
1663
|
+
const original = `${JSON.stringify({ tuiMode: "fullscreen" }, null, 2)}\n`;
|
|
1664
|
+
const current = `${JSON.stringify({ tuiMode: "fullscreen" }, null, 2)}\n`;
|
|
1665
|
+
const result = forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute");
|
|
1666
|
+
assert.equal(result, `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute" }, null, 2)}\n`);
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
test("forceJsonFieldIfAbsentInOriginal returns undefined when the original already declared the field", () => {
|
|
1670
|
+
const original = `${JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }, null, 2)}\n`;
|
|
1671
|
+
const current = `${JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }, null, 2)}\n`;
|
|
1672
|
+
assert.equal(forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute"), undefined);
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
test("forceJsonFieldIfAbsentInOriginal returns undefined when the original declared the field, even if the current text changed it", () => {
|
|
1676
|
+
const original = `${JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }, null, 2)}\n`;
|
|
1677
|
+
const current = `${JSON.stringify({ tuiMode: "fullscreen", theme: "kanagawa" }, null, 2)}\n`;
|
|
1678
|
+
assert.equal(forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute"), undefined);
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
test("forceJsonFieldIfAbsentInOriginal returns undefined when the current value already matches the forced value", () => {
|
|
1682
|
+
const original = `${JSON.stringify({ tuiMode: "fullscreen" }, null, 2)}\n`;
|
|
1683
|
+
const current = `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute" }, null, 2)}\n`;
|
|
1684
|
+
assert.equal(forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute"), undefined);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
test("forceJsonFieldIfAbsentInOriginal returns undefined for invalid original JSON", () => {
|
|
1688
|
+
assert.equal(forceJsonFieldIfAbsentInOriginal("not json", '{"theme":"kanagawa"}', "theme", "Gentleman-Cute"), undefined);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test("forceJsonFieldIfAbsentInOriginal returns undefined for invalid current JSON", () => {
|
|
1692
|
+
assert.equal(forceJsonFieldIfAbsentInOriginal("{}", "not json", "theme", "Gentleman-Cute"), undefined);
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
test("forceJsonFieldIfAbsentInOriginal matches the current text's own indentation and trailing-newline convention, not the original's", () => {
|
|
1696
|
+
const original = '{\n "tuiMode": "fullscreen"\n}\n';
|
|
1697
|
+
const current = '{"tuiMode":"fullscreen"}';
|
|
1698
|
+
const result = forceJsonFieldIfAbsentInOriginal(original, current, "theme", "Gentleman-Cute");
|
|
1699
|
+
assert.equal(result, '{"tuiMode":"fullscreen","theme":"Gentleman-Cute"}');
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
// --- shellQuote ---------------------------------------------------------------
|
|
1703
|
+
//
|
|
1704
|
+
// Used by bin/gentle-shell.mjs to build the copy-pasteable
|
|
1705
|
+
// `gentle-shell <home selector> remove <source>` remediation command it
|
|
1706
|
+
// prints after a failed conflicting-package removal: an unquoted --home
|
|
1707
|
+
// <dir> containing a space would silently split into two shell words if
|
|
1708
|
+
// copy-pasted (gentle-shell #1277 follow-up).
|
|
1709
|
+
|
|
1710
|
+
test("shellQuote leaves a plain token unchanged", () => {
|
|
1711
|
+
assert.equal(shellQuote("/Users/alan/.gentle-shell/agent"), "/Users/alan/.gentle-shell/agent");
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
test("shellQuote single-quotes a token containing a space", () => {
|
|
1715
|
+
assert.equal(shellQuote("/Users/alan/custom home"), "'/Users/alan/custom home'");
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
test("shellQuote escapes an embedded single quote as '\\''", () => {
|
|
1719
|
+
assert.equal(shellQuote("a'b"), "'a'\\''b'");
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1334
1722
|
// --- describeVersion / helpText ----------------------------------------------
|
|
1335
1723
|
|
|
1336
1724
|
test("describeVersion formats the three-line report with a found pi version", () => {
|
|
@@ -1356,6 +1744,12 @@ test("helpText documents the launcher flags, the home subcommand, the env vars,
|
|
|
1356
1744
|
assert.match(text, /forward/i);
|
|
1357
1745
|
});
|
|
1358
1746
|
|
|
1747
|
+
test("helpText documents the setup subcommand", () => {
|
|
1748
|
+
const text = helpText();
|
|
1749
|
+
assert.match(text, /\bsetup\b/);
|
|
1750
|
+
assert.match(text, /--dry-run/);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1359
1753
|
test("helpText documents pi's own package-management subcommands", () => {
|
|
1360
1754
|
const text = helpText();
|
|
1361
1755
|
assert.match(text, /\binstall\b/);
|
|
@@ -125,6 +125,24 @@ const unreachableComplete: typeof completeSimple = (async () => {
|
|
|
125
125
|
throw new Error("complete must not be called for this refusal");
|
|
126
126
|
}) as typeof completeSimple;
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A `getProvider` fake standing in for pi's composed provider: it records
|
|
130
|
+
* every dispatch and returns a stream whose `result()` settles the
|
|
131
|
+
* completion, which is exactly the shape pi-ai's own compat layer consumes.
|
|
132
|
+
* The registry it is spread onto keeps `fakeRegistry`'s default shape, so the
|
|
133
|
+
* no-`getProvider` seam the other tests use stays untouched.
|
|
134
|
+
*/
|
|
135
|
+
function capturingProvider(assistant: AssistantMessage) {
|
|
136
|
+
const calls: Array<{ provider: string; model: Model<Api>; context: Context; options: SimpleStreamOptions | undefined }> = [];
|
|
137
|
+
const getProvider = (provider: string) => ({
|
|
138
|
+
streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => {
|
|
139
|
+
calls.push({ provider, model, context, options });
|
|
140
|
+
return { result: async () => assistant };
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return { getProvider, calls };
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
function expectRefused(outcome: InProcessReviewerOutcome): Extract<InProcessReviewerOutcome, { kind: "refused" }> {
|
|
129
147
|
assert.equal(outcome.kind, "refused", outcome.kind === "text" ? `expected a refusal, got text: ${outcome.text}` : undefined);
|
|
130
148
|
return outcome as Extract<InProcessReviewerOutcome, { kind: "refused" }>;
|
|
@@ -458,3 +476,164 @@ test("concatenates only text parts, ignoring thinking parts, in order", async ()
|
|
|
458
476
|
assert.equal(text.text, "Part A Part B");
|
|
459
477
|
assert.equal(text.reviewerModel, "openai/gpt-5");
|
|
460
478
|
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Composed-provider routing (gentle-shell#1304)
|
|
482
|
+
//
|
|
483
|
+
// pi-ai's `completeSimple` resolves against pi-ai's own builtin-only
|
|
484
|
+
// `apiProviderRegistry`, so an extension-registered provider is unreachable
|
|
485
|
+
// through it ("No API provider registered for api: <api>"). A registry that
|
|
486
|
+
// exposes `getProvider` carries pi's composed provider, which does honor
|
|
487
|
+
// extensions, and must be used instead of `deps.complete`.
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
test("dispatches through the composed provider's streamSimple when the registry exposes getProvider", async () => {
|
|
491
|
+
const extensionModel = fakeModel({ provider: "claude-bridge", id: "claude-opus-5", api: "claude-bridge", baseUrl: "https://bridge.invalid" });
|
|
492
|
+
const { getProvider, calls } = capturingProvider(assistantText("bridged review"));
|
|
493
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection: "claude-bridge/claude-opus-5" }), {
|
|
494
|
+
// `unreachableComplete` is the canary: reaching pi-ai's compat path at
|
|
495
|
+
// all is the defect, so any call to it fails this test.
|
|
496
|
+
registry: { ...fakeRegistry([extensionModel]), getProvider },
|
|
497
|
+
complete: unreachableComplete,
|
|
498
|
+
});
|
|
499
|
+
const text = expectText(outcome);
|
|
500
|
+
assert.equal(text.text, "bridged review");
|
|
501
|
+
assert.equal(text.reviewerModel, "claude-bridge/claude-opus-5");
|
|
502
|
+
assert.equal(calls.length, 1, "the composed provider must receive exactly one dispatch");
|
|
503
|
+
assert.equal(calls[0]!.provider, "claude-bridge", "the provider is resolved by the model's own provider id");
|
|
504
|
+
assert.equal(calls[0]!.model.api, "claude-bridge", "the extension api must reach the composed provider unchanged");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("forwards the same context and options to the composed provider as to deps.complete", async () => {
|
|
508
|
+
const { getProvider, calls } = capturingProvider(assistantText("ok"));
|
|
509
|
+
const prompt = Buffer.from("frozen prompt bytes", "utf8");
|
|
510
|
+
const outcome = await runInProcessReviewer(baseRequest({ prompt, thinking: "high" }), {
|
|
511
|
+
registry: { ...fakeRegistry([fakeModel()]), getProvider },
|
|
512
|
+
complete: unreachableComplete,
|
|
513
|
+
now: () => 12_345,
|
|
514
|
+
});
|
|
515
|
+
expectText(outcome);
|
|
516
|
+
assert.deepEqual(calls[0]!.context, {
|
|
517
|
+
messages: [{ role: "user", content: [{ type: "text", text: "frozen prompt bytes" }], timestamp: 12_345 }],
|
|
518
|
+
});
|
|
519
|
+
assert.equal(calls[0]!.options?.apiKey, "test-key", "registry-resolved credentials still travel on options");
|
|
520
|
+
assert.equal(calls[0]!.options?.reasoning, "high");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("refuses instead of falling back when getProvider returns undefined for a model find() resolved", async () => {
|
|
524
|
+
const outcome = await runInProcessReviewer(baseRequest(), {
|
|
525
|
+
registry: { ...fakeRegistry([fakeModel()]), getProvider: () => undefined },
|
|
526
|
+
complete: unreachableComplete,
|
|
527
|
+
});
|
|
528
|
+
const refused = expectRefused(outcome);
|
|
529
|
+
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND);
|
|
530
|
+
assert.match(refused.message, /review-risk/);
|
|
531
|
+
assert.deepEqual(refused.evidence, { provider: "openai", api: "openai-responses" }, "the evidence separates this cause from a plain unresolved selection");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Env-API-key contract when the registry resolves no apiKey (gentle-shell#1304,
|
|
536
|
+
// IRP-4)
|
|
537
|
+
//
|
|
538
|
+
// pi-ai's compat layer wraps every dispatch in `withEnvApiKey`
|
|
539
|
+
// (@earendil-works/pi-ai/dist/compat.js:145-152, called at :190 and :193),
|
|
540
|
+
// which injects an API key read from the ambient environment whenever
|
|
541
|
+
// `options.apiKey` is absent or blank. pi's composed provider does no such
|
|
542
|
+
// thing: it forwards `options` verbatim. `ModelRegistry.getApiKeyAndHeaders`
|
|
543
|
+
// can legitimately resolve `{ ok: true, headers }` with no `apiKey` — a
|
|
544
|
+
// header-authenticated provider, for instance — so these tests pin what this
|
|
545
|
+
// module itself guarantees in that case, independently of which side settles
|
|
546
|
+
// the completion.
|
|
547
|
+
//
|
|
548
|
+
// The delta is not a regression. pi's own auth resolvers already read the same
|
|
549
|
+
// environment, through the same variable names, before this module dispatches:
|
|
550
|
+
// `getApiKeyAndHeaders` -> `ModelRuntime.getAuth` -> `Models.getAuth` ->
|
|
551
|
+
// `resolveProviderAuth` (dist/auth/resolve.js:51-54, the ambient branch) ->
|
|
552
|
+
// the provider's `ApiKeyAuth.resolve`, which reads `ctx.env(...)`
|
|
553
|
+
// (dist/auth/helpers.js:21-26) off `defaultProviderAuthContext`, whose `env`
|
|
554
|
+
// is `process.env` (dist/auth/context.js:19-24). So any key `withEnvApiKey`
|
|
555
|
+
// would have injected is one the registry has already returned as `apiKey`.
|
|
556
|
+
// What this module must never do is invent one itself: its header promises it
|
|
557
|
+
// "never reads process.env", and that promise is what these tests hold.
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
/** A registry auth resolution that succeeds with headers only — no `apiKey`. */
|
|
561
|
+
const HEADER_ONLY_AUTH_HEADERS = { Authorization: "Bearer resolved-by-the-registry" } as const;
|
|
562
|
+
const headerOnlyAuth = async () => ({ ok: true, headers: { ...HEADER_ONLY_AUTH_HEADERS } }) as const;
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Runs `body` with a synthetic value on the environment variable pi-ai's
|
|
566
|
+
* compat layer would read for the default fake model's provider
|
|
567
|
+
* (`openai` -> `OPENAI_API_KEY`, dist/env-api-keys.js:77), restoring the
|
|
568
|
+
* previous value afterwards. The value is a fixed placeholder, never a
|
|
569
|
+
* credential: the assertions only check that it does NOT appear on the
|
|
570
|
+
* forwarded options.
|
|
571
|
+
*/
|
|
572
|
+
async function withAmbientProviderApiKey<T>(body: () => Promise<T>): Promise<T> {
|
|
573
|
+
const name = "OPENAI_API_KEY";
|
|
574
|
+
const previous = process.env[name];
|
|
575
|
+
process.env[name] = "ambient-placeholder-the-module-must-never-read";
|
|
576
|
+
try {
|
|
577
|
+
return await body();
|
|
578
|
+
} finally {
|
|
579
|
+
if (previous === undefined) delete process.env[name];
|
|
580
|
+
else process.env[name] = previous;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
test("forwards no apiKey to the composed provider when the registry resolves auth without one", async () => {
|
|
585
|
+
const { getProvider, calls } = capturingProvider(assistantText("ok"));
|
|
586
|
+
const outcome = await withAmbientProviderApiKey(() =>
|
|
587
|
+
runInProcessReviewer(baseRequest(), {
|
|
588
|
+
registry: { ...fakeRegistry([fakeModel()], headerOnlyAuth), getProvider },
|
|
589
|
+
complete: unreachableComplete,
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
expectText(outcome);
|
|
593
|
+
assert.equal(calls.length, 1, "the composed provider must receive exactly one dispatch");
|
|
594
|
+
const options = calls[0]!.options;
|
|
595
|
+
assert.notEqual(options, undefined, "options must reach the composed provider");
|
|
596
|
+
assert.equal("apiKey" in options!, false, "the module must not invent an apiKey the registry did not resolve, nor read one from the ambient environment");
|
|
597
|
+
assert.deepEqual(options!.headers, { ...HEADER_ONLY_AUTH_HEADERS }, "the registry's own auth headers are the credential on this path");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("forwards no apiKey to deps.complete when the registry resolves auth without one", async () => {
|
|
601
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
602
|
+
const outcome = await withAmbientProviderApiKey(() =>
|
|
603
|
+
runInProcessReviewer(baseRequest(), {
|
|
604
|
+
registry: fakeRegistry([fakeModel()], headerOnlyAuth),
|
|
605
|
+
complete,
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
expectText(outcome);
|
|
609
|
+
assert.equal(calls.length, 1, "the fallback path must receive exactly one dispatch");
|
|
610
|
+
const options = calls[0]!.options;
|
|
611
|
+
assert.notEqual(options, undefined, "options must reach deps.complete");
|
|
612
|
+
assert.equal("apiKey" in options!, false, "the fallback path must not invent an apiKey either; compat's own withEnvApiKey is pi-ai's business, not this module's");
|
|
613
|
+
assert.deepEqual(options!.headers, { ...HEADER_ONLY_AUTH_HEADERS }, "the registry's own auth headers are the credential on this path too");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("the no-apiKey options shape is identical on the composed-provider and deps.complete paths", async () => {
|
|
617
|
+
const { getProvider, calls: providerCalls } = capturingProvider(assistantText("ok"));
|
|
618
|
+
const { complete, calls: completeCalls } = capturingComplete(assistantText("ok"));
|
|
619
|
+
await withAmbientProviderApiKey(async () => {
|
|
620
|
+
expectText(
|
|
621
|
+
await runInProcessReviewer(baseRequest(), {
|
|
622
|
+
registry: { ...fakeRegistry([fakeModel()], headerOnlyAuth), getProvider },
|
|
623
|
+
complete: unreachableComplete,
|
|
624
|
+
}),
|
|
625
|
+
);
|
|
626
|
+
expectText(
|
|
627
|
+
await runInProcessReviewer(baseRequest(), {
|
|
628
|
+
registry: fakeRegistry([fakeModel()], headerOnlyAuth),
|
|
629
|
+
complete,
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
// Option *keys* rather than values: the two runs carry different
|
|
634
|
+
// AbortSignal instances by construction, so only the shape is comparable.
|
|
635
|
+
const providerKeys = Object.keys(providerCalls[0]!.options ?? {}).sort();
|
|
636
|
+
const completeKeys = Object.keys(completeCalls[0]!.options ?? {}).sort();
|
|
637
|
+
assert.deepEqual(providerKeys, completeKeys, "routing through the composed provider must not change which credential fields this module forwards");
|
|
638
|
+
assert.equal(providerKeys.includes("apiKey"), false, "neither path may carry an apiKey the registry did not resolve");
|
|
639
|
+
});
|