gentle-pi 3.3.0 → 3.5.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 +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -7,6 +7,7 @@ import test from "node:test";
|
|
|
7
7
|
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
9
9
|
import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
10
|
+
import { USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
|
|
10
11
|
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
11
12
|
import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
12
13
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
@@ -129,7 +130,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
|
|
|
129
130
|
for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
133
|
+
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
133
134
|
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
134
135
|
let resolveOverlay: () => void;
|
|
135
136
|
const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
|
|
@@ -150,6 +151,9 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
|
|
|
150
151
|
getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
|
|
151
152
|
ui: {
|
|
152
153
|
theme: plainTheme,
|
|
154
|
+
// Added only when requested: an absent select keeps the no-menu fallback
|
|
155
|
+
// that every pre-existing test relies on.
|
|
156
|
+
...(options.select ? { select: options.select } : {}),
|
|
153
157
|
setFooter(factory: unknown) {
|
|
154
158
|
ui.footerFactory = factory;
|
|
155
159
|
},
|
|
@@ -475,7 +479,122 @@ test("gentleShell shows working while the agent runs and queued when messages wa
|
|
|
475
479
|
editor.dispose();
|
|
476
480
|
});
|
|
477
481
|
|
|
482
|
+
test("animations command reports without writing and switches the live pulse", async (t) => {
|
|
483
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
484
|
+
const delays: number[] = [];
|
|
485
|
+
let active = 0;
|
|
486
|
+
t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
|
|
487
|
+
delays.push(delay); active++; return { unref() {} };
|
|
488
|
+
});
|
|
489
|
+
t.mock.method(globalThis, "clearInterval", () => { active--; });
|
|
490
|
+
const { pi, handlers, commands } = fakePi();
|
|
491
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
492
|
+
const { ctx, ui } = fakeContext();
|
|
493
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
494
|
+
const command = commands.get("gentle:animations");
|
|
495
|
+
assert.ok(command);
|
|
496
|
+
await command.handler("", ctx);
|
|
497
|
+
assert.match(ui.notices.at(-1)!, /animations: quality/);
|
|
498
|
+
assert.equal(existsSync(join(configHome, "animations.json")), false);
|
|
499
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
500
|
+
assert.deepEqual(delays, [80]);
|
|
501
|
+
await command.handler("performance", ctx);
|
|
502
|
+
assert.deepEqual(delays, [80, 1000]);
|
|
503
|
+
assert.equal(active, 1);
|
|
504
|
+
await command.handler("potato", ctx);
|
|
505
|
+
assert.equal(active, 0);
|
|
506
|
+
assert.match(stripAnsi(editor.render(60)[0]), /working/);
|
|
507
|
+
assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
|
|
508
|
+
await command.handler("invalid", ctx);
|
|
509
|
+
assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
|
|
510
|
+
await command.handler("quality", ctx);
|
|
511
|
+
assert.deepEqual(delays, [80, 1000, 80]);
|
|
512
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
513
|
+
assert.equal(active, 0);
|
|
514
|
+
editor.dispose();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("potato repaints start/settle and shows queued state on the host's next render without intervals", async (t) => {
|
|
518
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
519
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
|
|
520
|
+
const intervals = t.mock.method(globalThis, "setInterval", () => { throw new Error("potato must not animate"); });
|
|
521
|
+
const renders = t.mock.method(fakeTui, "requestRender", () => {});
|
|
522
|
+
const { pi, handlers, commands } = fakePi();
|
|
523
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
524
|
+
const { ctx, ui } = fakeContext();
|
|
525
|
+
const pending = { value: false };
|
|
526
|
+
(ctx as unknown as { hasPendingMessages(): boolean }).hasPendingMessages = () => pending.value;
|
|
527
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
528
|
+
let before = renders.mock.callCount();
|
|
529
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
530
|
+
assert.ok(renders.mock.callCount() > before);
|
|
531
|
+
assert.match(stripAnsi(editor.render(60)[0]), /✿ working/);
|
|
532
|
+
before = renders.mock.callCount();
|
|
533
|
+
pending.value = true;
|
|
534
|
+
assert.equal(renders.mock.callCount(), before, "changing the queue flag is not a Gentle repaint event");
|
|
535
|
+
// Pi owns enqueue and its repaint scheduling; this fake context exposes only
|
|
536
|
+
// hasPendingMessages, not Pi's enqueue path. Simulate the host's render request:
|
|
537
|
+
// this proves next-render visibility, not real Pi enqueue-to-paint latency.
|
|
538
|
+
fakeTui.requestRender();
|
|
539
|
+
assert.equal(renders.mock.callCount(), before + 1);
|
|
540
|
+
assert.match(stripAnsi(editor.render(60)[0]), /✿ queued/);
|
|
541
|
+
before = renders.mock.callCount();
|
|
542
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
543
|
+
assert.ok(renders.mock.callCount() > before);
|
|
544
|
+
assert.doesNotMatch(stripAnsi(editor.render(60)[0]), /working|queued/);
|
|
545
|
+
await commands.get("gentle:animations")!.handler("status", ctx);
|
|
546
|
+
assert.match(ui.notices.at(-1)!, /animations: potato/);
|
|
547
|
+
for (const handler of handlers.get("session_shutdown") ?? []) handler({}, ctx);
|
|
548
|
+
assert.equal(intervals.mock.callCount(), 0);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("animations status attributes malformed files and reports a failed write", async (t) => {
|
|
552
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
553
|
+
const path = join(configHome, "animations.json");
|
|
554
|
+
writeFileSync(path, "broken");
|
|
555
|
+
const { pi, commands } = fakePi();
|
|
556
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
557
|
+
const { ctx, ui } = fakeContext();
|
|
558
|
+
await commands.get("gentle:animations")!.handler("", ctx);
|
|
559
|
+
assert.match(ui.notices.at(-1)!, /animations: quality.*global file.*malformed/);
|
|
560
|
+
assert.equal(readFileSync(path, "utf8"), "broken");
|
|
561
|
+
rmSync(path);
|
|
562
|
+
mkdirSync(path);
|
|
563
|
+
await commands.get("gentle:animations")!.handler("potato", ctx);
|
|
564
|
+
assert.match(ui.notices.at(-1)!, /EISDIR|ENOTEMPTY|EPERM/);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("animations with no argument opens a selectable menu and applies the chosen policy", async (t) => {
|
|
568
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
569
|
+
const path = join(configHome, "animations.json");
|
|
570
|
+
const { pi, commands } = fakePi();
|
|
571
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
572
|
+
// No editor/prompt is installed: the handler's `prompt?.setAnimationPolicy`
|
|
573
|
+
// optional chain must tolerate the interactive menu without one.
|
|
574
|
+
const chosen = fakeContext({
|
|
575
|
+
select: async (title, options) => {
|
|
576
|
+
assert.match(title, /Gentle animations/);
|
|
577
|
+
assert.deepEqual(options, ["quality", "performance", "potato", "status"]);
|
|
578
|
+
return "potato";
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
await commands.get("gentle:animations")!.handler("", chosen.ctx);
|
|
582
|
+
assert.equal(JSON.parse(readFileSync(path, "utf8")).policy, "potato");
|
|
583
|
+
assert.match(chosen.ui.notices.at(-1)!, /animations: potato/);
|
|
584
|
+
|
|
585
|
+
// A dismissed menu (undefined selection) reports nothing and writes nothing.
|
|
586
|
+
const dismissHome = scopedDoubleEscCancelConfigHome(t);
|
|
587
|
+
const { pi: dismissPi, commands: dismissCommands } = fakePi();
|
|
588
|
+
gentleShell(dismissPi, { GENTLE_PI_CONFIG_HOME: dismissHome });
|
|
589
|
+
const dismissed = fakeContext({ select: async () => undefined });
|
|
590
|
+
await dismissCommands.get("gentle:animations")!.handler("", dismissed.ctx);
|
|
591
|
+
assert.equal(dismissed.ui.notices.length, 0);
|
|
592
|
+
assert.equal(existsSync(join(dismissHome, "animations.json")), false);
|
|
593
|
+
});
|
|
594
|
+
|
|
478
595
|
test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
|
|
596
|
+
const configHome = scopedDoubleEscCancelConfigHome(t);
|
|
597
|
+
writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
|
|
479
598
|
const delays: number[] = [];
|
|
480
599
|
let active = 0;
|
|
481
600
|
let unrefs = 0;
|
|
@@ -486,7 +605,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
|
|
|
486
605
|
});
|
|
487
606
|
t.mock.method(globalThis, "clearInterval", () => { active--; });
|
|
488
607
|
const { pi, handlers } = fakePi();
|
|
489
|
-
gentleShell(pi, {});
|
|
608
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
|
|
490
609
|
const { ctx, ui } = fakeContext();
|
|
491
610
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
492
611
|
assert.equal(active, 0);
|
|
@@ -1594,6 +1713,240 @@ test("a provider switch refreshes the new provider inside the same window", asyn
|
|
|
1594
1713
|
assert.equal(calls[1].url, "https://cloud-api.nan.builders/api/usage/quota");
|
|
1595
1714
|
});
|
|
1596
1715
|
|
|
1716
|
+
// A generic hook: any extension can register a usage source for its own
|
|
1717
|
+
// provider on `pi.events`, and gentle-shell dispatches to it exactly like it
|
|
1718
|
+
// dispatches to the built-in Codex/NaN fetchers, without knowing the
|
|
1719
|
+
// provider's name ahead of time.
|
|
1720
|
+
|
|
1721
|
+
function acmeSource(onFetch: (apiKey: string | undefined) => void, plan = "Acme cloud"): unknown {
|
|
1722
|
+
return {
|
|
1723
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1724
|
+
provider: "acme-cloud",
|
|
1725
|
+
fetch: async (apiKey: string | undefined) => {
|
|
1726
|
+
onFetch(apiKey);
|
|
1727
|
+
return {
|
|
1728
|
+
provider: "acme-cloud",
|
|
1729
|
+
plan,
|
|
1730
|
+
limits: [{ name: "acme-cloud", windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }], limitReached: false }],
|
|
1731
|
+
fetchedAt: 0,
|
|
1732
|
+
};
|
|
1733
|
+
},
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
test("gentleShell fetches usage through a source registered before session start", async () => {
|
|
1738
|
+
const { pi, handlers } = fakePi();
|
|
1739
|
+
const seen: Array<string | undefined> = [];
|
|
1740
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1741
|
+
pi.events.emit(USAGE_SOURCE_EVENT, acmeSource((apiKey) => seen.push(apiKey)));
|
|
1742
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1743
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1744
|
+
await fire(handlers, "session_start", ctx);
|
|
1745
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1746
|
+
assert.deepEqual(seen, ["acme-token"], "the registered fetch gets the api key modelRegistry resolves for its provider");
|
|
1747
|
+
assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
test("gentleShell forces one refresh when a usage source registers after session start for the active provider", async () => {
|
|
1751
|
+
const { pi, handlers } = fakePi();
|
|
1752
|
+
let calls = 0;
|
|
1753
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1754
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1755
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1756
|
+
await fire(handlers, "session_start", ctx);
|
|
1757
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1758
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no source registered yet, nothing to show");
|
|
1759
|
+
|
|
1760
|
+
pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
|
|
1761
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1762
|
+
assert.equal(calls, 1, "late registration for the active provider triggers one forced refresh, not a wait for the next turn");
|
|
1763
|
+
assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
test("a usage source registered for a different provider does not force a refresh", async () => {
|
|
1767
|
+
const { pi, handlers } = fakePi();
|
|
1768
|
+
let calls = 0;
|
|
1769
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1770
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1771
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "openai-codex";
|
|
1772
|
+
await fire(handlers, "session_start", ctx);
|
|
1773
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1774
|
+
|
|
1775
|
+
pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
|
|
1776
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1777
|
+
assert.equal(calls, 0, "the active provider is codex, so an acme-cloud registration fetches nothing yet");
|
|
1778
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
test("gentleShell shows the unsupported note for a provider with no built-in or registered source", async () => {
|
|
1782
|
+
const { pi, handlers, commands } = fakePi();
|
|
1783
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1784
|
+
const { ctx, ui } = fakeContext({ token: undefined });
|
|
1785
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1786
|
+
await fire(handlers, "session_start", ctx);
|
|
1787
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
1788
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1789
|
+
const plain = ui.overlayView!.render(90).map(stripAnsi);
|
|
1790
|
+
assert.match(plain[1], /✿ acme-cloud · no subscription usage for this provider/);
|
|
1791
|
+
ui.closeOverlay?.();
|
|
1792
|
+
await opened;
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
test("gentleShell ignores a malformed usage-source registration payload", async () => {
|
|
1796
|
+
const { pi, handlers } = fakePi();
|
|
1797
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1798
|
+
pi.events.emit(USAGE_SOURCE_EVENT, { schema: "wrong-schema", provider: "acme-cloud", fetch: async () => undefined });
|
|
1799
|
+
pi.events.emit(USAGE_SOURCE_EVENT, { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: "not-a-function" });
|
|
1800
|
+
pi.events.emit(USAGE_SOURCE_EVENT, "not-an-object");
|
|
1801
|
+
pi.events.emit(USAGE_SOURCE_EVENT, undefined);
|
|
1802
|
+
const { ctx, ui } = fakeContext({ token: undefined });
|
|
1803
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1804
|
+
await fire(handlers, "session_start", ctx);
|
|
1805
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1806
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no malformed payload registered a usable source");
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
// A registered source is foreign code running inside a fire-and-forget
|
|
1810
|
+
// refresh (`void refreshUsage(...)`, both at session_start and on a late
|
|
1811
|
+
// registration): if its fetch rejects and nothing catches it, the rejection
|
|
1812
|
+
// is unhandled and, under Node's default policy, takes the whole process
|
|
1813
|
+
// down. The doc promises a foreign source is "ignored rather than crashing
|
|
1814
|
+
// the shell", so a throw must degrade exactly like a built-in fetcher's.
|
|
1815
|
+
test("a registered source's rejecting fetch never crashes the shell or poisons the store", async () => {
|
|
1816
|
+
const { pi, handlers, commands } = fakePi();
|
|
1817
|
+
let calls = 0;
|
|
1818
|
+
const rejectingSource = () => ({
|
|
1819
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1820
|
+
provider: "acme-cloud",
|
|
1821
|
+
fetch: async () => {
|
|
1822
|
+
calls += 1;
|
|
1823
|
+
throw new Error("acme is down");
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1827
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1828
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1829
|
+
|
|
1830
|
+
const unhandled: unknown[] = [];
|
|
1831
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
1832
|
+
process.on("unhandledRejection", onUnhandled);
|
|
1833
|
+
try {
|
|
1834
|
+
// Registered before session start: session_start's own fire-and-forget
|
|
1835
|
+
// refresh dispatches straight to the rejecting fetch.
|
|
1836
|
+
pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
|
|
1837
|
+
await fire(handlers, "session_start", ctx);
|
|
1838
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1839
|
+
assert.equal(calls, 1);
|
|
1840
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a rejecting fetch must never be recorded as a snapshot");
|
|
1841
|
+
|
|
1842
|
+
// A second, late registration for the already-active provider dispatches
|
|
1843
|
+
// through the USAGE_SOURCE_EVENT handler's own fire-and-forget refresh.
|
|
1844
|
+
pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
|
|
1845
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1846
|
+
assert.equal(calls, 2);
|
|
1847
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
|
|
1848
|
+
} finally {
|
|
1849
|
+
process.off("unhandledRejection", onUnhandled);
|
|
1850
|
+
}
|
|
1851
|
+
assert.deepEqual(unhandled, [], "a foreign source's rejection must never surface as an unhandled rejection");
|
|
1852
|
+
|
|
1853
|
+
// The panel still explains itself with the pending note, never a crash,
|
|
1854
|
+
// even through the awaited refresh openUsage runs on open.
|
|
1855
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
1856
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1857
|
+
const plain = ui.overlayView!.render(90).map(stripAnsi);
|
|
1858
|
+
assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
|
|
1859
|
+
ui.closeOverlay?.();
|
|
1860
|
+
await opened;
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// A registered source's resolved value is foreign code's own object: it must
|
|
1864
|
+
// be validated like any other parsed payload (never trusted to name the
|
|
1865
|
+
// provider it actually is, never trusted to be well-formed), and a source
|
|
1866
|
+
// that gets replaced mid-flight must never let its late, stale answer land
|
|
1867
|
+
// after the replacement already recorded its own.
|
|
1868
|
+
|
|
1869
|
+
test("a registered source resolving usage for another provider is rejected without overwriting that provider's snapshot", async () => {
|
|
1870
|
+
const { pi, handlers } = fakePi();
|
|
1871
|
+
const { fetchFn } = fakeFetch(NAN_QUOTA_PAYLOAD);
|
|
1872
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
|
|
1873
|
+
const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
|
|
1874
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "nan";
|
|
1875
|
+
await fire(handlers, "session_start", ctx);
|
|
1876
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1877
|
+
assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot recorded first");
|
|
1878
|
+
|
|
1879
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1880
|
+
pi.events.emit(USAGE_SOURCE_EVENT, {
|
|
1881
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1882
|
+
provider: "acme-cloud",
|
|
1883
|
+
fetch: async () => ({ provider: "nan", plan: undefined, limits: [], fetchedAt: 0 }),
|
|
1884
|
+
});
|
|
1885
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1886
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a resolution naming another provider must not surface as the active one");
|
|
1887
|
+
|
|
1888
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "nan";
|
|
1889
|
+
assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot must survive a mismatched acme-cloud resolution untouched");
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
test("gentleShell leaves the pending note when a registered source resolves a malformed usage", async () => {
|
|
1893
|
+
const { pi, handlers, commands } = fakePi();
|
|
1894
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1895
|
+
pi.events.emit(USAGE_SOURCE_EVENT, {
|
|
1896
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1897
|
+
provider: "acme-cloud",
|
|
1898
|
+
fetch: async () => ({ provider: "acme-cloud", plan: "Acme", fetchedAt: 0, limits: "nope" }),
|
|
1899
|
+
});
|
|
1900
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1901
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1902
|
+
await fire(handlers, "session_start", ctx);
|
|
1903
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1904
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a malformed result must never be recorded");
|
|
1905
|
+
|
|
1906
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
1907
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1908
|
+
const plain = ui.overlayView!.render(90).map(stripAnsi);
|
|
1909
|
+
assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
|
|
1910
|
+
ui.closeOverlay?.();
|
|
1911
|
+
await opened;
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
test("a slow fetch from a replaced source never records after its replacement resolves", async () => {
|
|
1915
|
+
const { pi, handlers } = fakePi();
|
|
1916
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
|
|
1917
|
+
const { ctx, ui } = fakeContext({ token: "acme-token" });
|
|
1918
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
|
|
1919
|
+
|
|
1920
|
+
let resolveSlow!: (value: unknown) => void;
|
|
1921
|
+
const slow = new Promise((resolve) => { resolveSlow = resolve; });
|
|
1922
|
+
pi.events.emit(USAGE_SOURCE_EVENT, {
|
|
1923
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1924
|
+
provider: "acme-cloud",
|
|
1925
|
+
fetch: async () => {
|
|
1926
|
+
await slow;
|
|
1927
|
+
return { provider: "acme-cloud", plan: "Stale", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 10, windowSeconds: 604_800, resetAt: null }] }] };
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
await fire(handlers, "session_start", ctx);
|
|
1931
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1932
|
+
assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "the slow fetch has not resolved yet");
|
|
1933
|
+
|
|
1934
|
+
// Replace the source before the slow fetch resolves; the late registration
|
|
1935
|
+
// forces its own refresh, which resolves immediately.
|
|
1936
|
+
pi.events.emit(USAGE_SOURCE_EVENT, {
|
|
1937
|
+
schema: USAGE_SOURCE_SCHEMA,
|
|
1938
|
+
provider: "acme-cloud",
|
|
1939
|
+
fetch: async () => ({ provider: "acme-cloud", plan: "Fresh", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }] }] }),
|
|
1940
|
+
});
|
|
1941
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1942
|
+
assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the replacement's own refresh recorded first");
|
|
1943
|
+
|
|
1944
|
+
// Now let the stale fetch resolve; it must never overwrite the fresh record.
|
|
1945
|
+
resolveSlow(undefined);
|
|
1946
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1947
|
+
assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the stale refresh must never record after being replaced");
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1597
1950
|
test("gentleShell records SSE rate-limit headers from provider responses", async () => {
|
|
1598
1951
|
const { pi, handlers } = fakePi();
|
|
1599
1952
|
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
INPROCESS_REVIEWER_FAILURE,
|
|
7
7
|
INPROCESS_REVIEWER_OUTPUT_MAX_BYTES,
|
|
8
8
|
runInProcessReviewer,
|
|
9
|
+
openCodeSessionAttributionHeaders,
|
|
9
10
|
type InProcessReviewerOutcome,
|
|
10
11
|
type InProcessReviewerRegistry,
|
|
11
12
|
type InProcessReviewerRequest,
|
|
@@ -345,6 +346,97 @@ test("omits reasoning for a non-reasoning model even with a valid label", async
|
|
|
345
346
|
assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
|
|
346
347
|
});
|
|
347
348
|
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// OpenCode session attribution headers
|
|
351
|
+
//
|
|
352
|
+
// Pi adds OpenCode attribution headers inside the main agent loop
|
|
353
|
+
// (provider-attribution.js#getSessionHeaders). This completion is an extension
|
|
354
|
+
// side-call that bypasses that loop, so it must add the same headers itself:
|
|
355
|
+
// provider opencode / opencode-go, or a baseUrl whose host is opencode.ai,
|
|
356
|
+
// carrying the live session id — and nothing for any other provider.
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
const OPENCODE_ATTRIBUTION_TESTS: Array<{ readonly name: string; readonly model: Partial<Model<Api>>; readonly selection: string }> = [
|
|
360
|
+
{ name: "an opencode provider model", model: { provider: "opencode", id: "sonnet-4", baseUrl: "https://opencode.ai" }, selection: "opencode/sonnet-4" },
|
|
361
|
+
{ name: "an opencode-go provider model", model: { provider: "opencode-go", id: "gpt-5", baseUrl: "https://opencode.ai" }, selection: "opencode-go/gpt-5" },
|
|
362
|
+
{ name: "a custom provider whose baseUrl host is opencode.ai", model: { provider: "custom", id: "relay-model", baseUrl: "https://opencode.ai/v1" }, selection: "custom/relay-model" },
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
|
|
366
|
+
test(`${name} receives both attribution headers carrying the live session id`, async () => {
|
|
367
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
368
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection, sessionId: "ses-live-1" }), {
|
|
369
|
+
registry: fakeRegistry([fakeModel(model)]),
|
|
370
|
+
complete,
|
|
371
|
+
});
|
|
372
|
+
expectText(outcome);
|
|
373
|
+
assert.equal(calls.length, 1);
|
|
374
|
+
assert.equal(calls[0]!.options?.headers?.["x-opencode-session"], "ses-live-1");
|
|
375
|
+
assert.equal(calls[0]!.options?.headers?.["x-opencode-client"], "pi");
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
|
|
380
|
+
test(`${name} without a session id adds no attribution header and still completes`, async () => {
|
|
381
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
382
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection }), {
|
|
383
|
+
registry: fakeRegistry([fakeModel(model)]),
|
|
384
|
+
complete,
|
|
385
|
+
});
|
|
386
|
+
expectText(outcome);
|
|
387
|
+
assert.equal(calls.length, 1);
|
|
388
|
+
assert.ok(!("headers" in (calls[0]!.options ?? {})), "a missing session id must never invent a header");
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
test("a non-OpenCode model adds no attribution headers and leaves the options unchanged", async () => {
|
|
393
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
394
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection: "anthropic/claude-sonnet-4", sessionId: "ses-live-1" }), {
|
|
395
|
+
registry: fakeRegistry([fakeModel({ provider: "anthropic", id: "claude-sonnet-4", baseUrl: "https://api.anthropic.com" })]),
|
|
396
|
+
complete,
|
|
397
|
+
});
|
|
398
|
+
expectText(outcome);
|
|
399
|
+
assert.equal(calls.length, 1);
|
|
400
|
+
assert.ok(!("headers" in (calls[0]!.options ?? {})));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("registry auth headers win over the attribution defaults and both survive the merge", async () => {
|
|
404
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
405
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection: "opencode/sonnet-4", sessionId: "ses-live-1" }), {
|
|
406
|
+
registry: fakeRegistry([fakeModel({ provider: "opencode", id: "sonnet-4" })], async () => ({
|
|
407
|
+
ok: true,
|
|
408
|
+
headers: { "x-api-key": "registry-key", "x-opencode-session": "registry-session" },
|
|
409
|
+
})),
|
|
410
|
+
complete,
|
|
411
|
+
});
|
|
412
|
+
expectText(outcome);
|
|
413
|
+
const headers = calls[0]!.options?.headers;
|
|
414
|
+
assert.equal(headers?.["x-api-key"], "registry-key", "registry auth headers must survive");
|
|
415
|
+
assert.equal(headers?.["x-opencode-session"], "registry-session", "explicit registry headers must not be clobbered by the attribution default");
|
|
416
|
+
assert.equal(headers?.["x-opencode-client"], "pi");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("an unparseable baseUrl never throws: attribution follows the provider condition only", async () => {
|
|
420
|
+
const { complete, calls } = capturingComplete(assistantText("ok"));
|
|
421
|
+
const outcome = await runInProcessReviewer(baseRequest({ selection: "custom/relay-model", sessionId: "ses-live-1" }), {
|
|
422
|
+
registry: fakeRegistry([fakeModel({ provider: "custom", id: "relay-model", baseUrl: "not-a-url" })]),
|
|
423
|
+
complete,
|
|
424
|
+
});
|
|
425
|
+
expectText(outcome);
|
|
426
|
+
assert.ok(!("headers" in (calls[0]!.options ?? {})));
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("openCodeSessionAttributionHeaders mirrors pi's condition exactly", () => {
|
|
430
|
+
const opencode = fakeModel({ provider: "opencode", baseUrl: "https://example.com" });
|
|
431
|
+
assert.deepEqual(openCodeSessionAttributionHeaders(opencode, "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
|
|
432
|
+
assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "opencode-go" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
|
|
433
|
+
assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://opencode.ai/v1" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
|
|
434
|
+
assert.equal(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://api.opencode.ai" }), "ses-1"), undefined, "a subdomain host is not opencode.ai");
|
|
435
|
+
assert.equal(openCodeSessionAttributionHeaders(fakeModel(), "ses-1"), undefined);
|
|
436
|
+
assert.equal(openCodeSessionAttributionHeaders(opencode, undefined), undefined, "no session id, no header");
|
|
437
|
+
assert.equal(openCodeSessionAttributionHeaders(opencode, ""), undefined, "an empty session id is no session id");
|
|
438
|
+
});
|
|
439
|
+
|
|
348
440
|
// ---------------------------------------------------------------------------
|
|
349
441
|
// Text concatenation ignoring thinking parts
|
|
350
442
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
// isPiManagedInstall gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs):
|
|
9
|
+
// it decides whether the running gentle-pi package sits under a directory
|
|
10
|
+
// pattern Pi's own package manager creates (npm-backed or git-backed), so an
|
|
11
|
+
// `npm install -g gentle-pi`, a plain development git checkout, or an npx
|
|
12
|
+
// cache directory never writes to the user's global Pi settings. It is
|
|
13
|
+
// deliberately unrelated to installTuiModeSetting's own ownership check
|
|
14
|
+
// (which compares the package location against one specific resolved agent
|
|
15
|
+
// home) and to installIsolatedTuiModeSetting (gentle-shell's own bootstrap of
|
|
16
|
+
// a directory it just created), covered in the other two test files.
|
|
17
|
+
|
|
18
|
+
const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
|
|
19
|
+
const { isPiManagedInstall } = await import(helperUrl.href);
|
|
20
|
+
|
|
21
|
+
test("isPiManagedInstall recognizes the user-scope npm-managed layout", () => {
|
|
22
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "npm", "node_modules", "gentle-pi")), true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("isPiManagedInstall recognizes the project-scope npm-managed layout", () => {
|
|
26
|
+
assert.equal(isPiManagedInstall(join("/repo", ".pi", "npm", "node_modules", "gentle-pi")), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isPiManagedInstall recognizes Pi's git-managed layout", () => {
|
|
30
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "git", "github.com", "Gentleman-Programming", "gentle-pi")), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("isPiManagedInstall rejects a global npm -g install", () => {
|
|
34
|
+
assert.equal(isPiManagedInstall("/usr/local/lib/node_modules/gentle-pi"), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("isPiManagedInstall rejects a plain development git checkout", () => {
|
|
38
|
+
assert.equal(isPiManagedInstall("/Users/alan/work/gentle-pi"), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("isPiManagedInstall rejects an npx cache directory", () => {
|
|
42
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".npm", "_npx", "abc123", "node_modules", "gentle-pi")), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("isPiManagedInstall rejects a pnpm content-addressable store", () => {
|
|
46
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pnpm", "gentle-pi@1.0.0", "node_modules", "gentle-pi")), false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("isPiManagedInstall resolves a relative path before checking", () => {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
try {
|
|
52
|
+
process.chdir(join("/tmp"));
|
|
53
|
+
assert.equal(isPiManagedInstall(join(".", "npm", "node_modules", "gentle-pi")), true);
|
|
54
|
+
} finally {
|
|
55
|
+
process.chdir(cwd);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- postinstall entry integration --------------------------------------------
|
|
60
|
+
|
|
61
|
+
function fixture(t: test.TestContext, packageSegments: readonly string[]) {
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-guard-"));
|
|
63
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
64
|
+
const home = join(root, "agent");
|
|
65
|
+
const packageRoot = join(home, ...packageSegments);
|
|
66
|
+
const scripts = join(packageRoot, "scripts");
|
|
67
|
+
mkdirSync(scripts, { recursive: true });
|
|
68
|
+
copyFileSync(helperUrl, join(scripts, "install-tui-mode-setting.mjs"));
|
|
69
|
+
copyFileSync(new URL("../scripts/install-gentle-ai.mjs", import.meta.url), join(scripts, "install-gentle-ai.mjs"));
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(scripts, "gentle-ai-installer.mjs"),
|
|
72
|
+
'export const INSTALLER_VERSION = "test"; export async function installGentleAi() { return { installed: true, binaryPath: "fixture" }; }',
|
|
73
|
+
);
|
|
74
|
+
const settings = join(home, "settings.json");
|
|
75
|
+
const env = { ...process.env, GENTLE_PI_AGENT_HOME: home, GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "0" };
|
|
76
|
+
return { root, home, packageRoot, scripts, settings, env };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("postinstall skips the global settings write outside a pi-managed install", (t) => {
|
|
80
|
+
const f = fixture(t, ["node_modules", "gentle-pi"]);
|
|
81
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
82
|
+
assert.equal(result.status, 0, result.stderr);
|
|
83
|
+
assert.match(`${result.stdout}${result.stderr}`, /skip/i);
|
|
84
|
+
assert.equal(existsSync(f.settings), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("postinstall still writes fullscreen for a pi-managed npm install", (t) => {
|
|
88
|
+
const f = fixture(t, ["npm", "node_modules", "gentle-pi"]);
|
|
89
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
90
|
+
assert.equal(result.status, 0, result.stderr);
|
|
91
|
+
assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("postinstall still writes fullscreen for a pi-managed git install", (t) => {
|
|
95
|
+
const f = fixture(t, ["git", "github.com", "Gentleman-Programming", "gentle-pi"]);
|
|
96
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
97
|
+
assert.equal(result.status, 0, result.stderr);
|
|
98
|
+
assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
|
|
99
|
+
});
|
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
|
|
9
9
|
const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
|
|
10
|
-
const { installTuiModeSetting } = await import(helperUrl.href);
|
|
10
|
+
const { installTuiModeSetting, installIsolatedTuiModeSetting } = await import(helperUrl.href);
|
|
11
11
|
|
|
12
12
|
function fixture(t: { after(fn: () => void): void }, packagePath: readonly string[] = ["npm", "node_modules", "gentle-pi"]) {
|
|
13
13
|
const root = mkdtempSync(join(tmpdir(), "gentle-tui-test-"));
|
|
@@ -269,6 +269,44 @@ for (const fault of ["write", "rename", "concurrent-change"]) {
|
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// --- installIsolatedTuiModeSetting -------------------------------------------
|
|
273
|
+
// gentle-shell's own isolated-home bootstrap (T2): unlike installTuiModeSetting,
|
|
274
|
+
// there is no "physically installed under this home's npm/node_modules"
|
|
275
|
+
// ownership check to satisfy, because gentle-shell just created `dir` itself
|
|
276
|
+
// moments ago; a plain agent-home-shaped temp directory is enough.
|
|
277
|
+
|
|
278
|
+
test("installIsolatedTuiModeSetting writes fullscreen into a freshly created directory", async (t) => {
|
|
279
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
280
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
281
|
+
const dir = join(root, "gentle-shell", "agent");
|
|
282
|
+
mkdirSync(dir, { recursive: true });
|
|
283
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(dir), { changed: true, recognized: true });
|
|
284
|
+
assert.deepEqual(JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")), { tuiMode: "fullscreen" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("installIsolatedTuiModeSetting preserves other settings fields already present", async (t) => {
|
|
288
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
289
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
290
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "regular", theme: "rose" }));
|
|
291
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: true, recognized: true });
|
|
292
|
+
assert.deepEqual(JSON.parse(readFileSync(join(root, "settings.json"), "utf8")), { tuiMode: "fullscreen", theme: "rose" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("installIsolatedTuiModeSetting is a no-op when already fullscreen", async (t) => {
|
|
296
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
297
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
298
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
|
|
299
|
+
const before = lstatSync(join(root, "settings.json"));
|
|
300
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: false, recognized: true });
|
|
301
|
+
assert.equal(lstatSync(join(root, "settings.json")).ino, before.ino);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("installIsolatedTuiModeSetting rejects a missing directory", async (t) => {
|
|
305
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
306
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
307
|
+
await assert.rejects(installIsolatedTuiModeSetting(join(root, "absent")));
|
|
308
|
+
});
|
|
309
|
+
|
|
272
310
|
type PostinstallRoute = "skip" | "success" | "failure";
|
|
273
311
|
|
|
274
312
|
function writePostinstallFixture(f: ReturnType<typeof fixture>, route: PostinstallRoute) {
|