gentle-pi 3.3.0 → 3.4.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- 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-shell.md +26 -2
- package/docs/readme-reference.md +32 -6
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +8 -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/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -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/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 +1 -1
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -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 +71 -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.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- 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/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
|
// ---------------------------------------------------------------------------
|
|
@@ -319,11 +319,26 @@ test("3.4.0 repeats 3.2.1 because the negotiated lane Pi consumes is unchanged",
|
|
|
319
319
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
+
test("3.5.0 repeats 3.4.0 because the negotiated lane Pi consumes is unchanged", () => {
|
|
323
|
+
// Published v3.4.0 and v3.5.0 provider bundles are byte-identical at
|
|
324
|
+
// contract 1.2.0, both with archive SHA-256:
|
|
325
|
+
// 547b68e172cc87aa297309d61624e5fc2c24d407a494b53eeb5a2b053904352c.
|
|
326
|
+
// Both binaries advertise capabilities/v2.6; only build-identity fields
|
|
327
|
+
// differ. No review-integration schema changed between the tags. The v2
|
|
328
|
+
// preflight failure identity fix changes no closed START/STATUS field
|
|
329
|
+
// this row negotiates, so it repeats 3.4.0. riskEvidence and hint remain
|
|
330
|
+
// dark because neither is proven to reach Pi's negotiated START path.
|
|
331
|
+
const contract = NATIVE_CLI_CONTRACTS["3.5.0"] as Record<string, boolean>;
|
|
332
|
+
assert.equal(contract.riskEvidence, false);
|
|
333
|
+
assert.equal(contract.hint, false);
|
|
334
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.4.0"] as Record<string, boolean>);
|
|
335
|
+
});
|
|
336
|
+
|
|
322
337
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
323
338
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
324
339
|
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
325
340
|
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
326
341
|
// so they get no row: a row asserts ground truth measured against a binary
|
|
327
342
|
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
328
|
-
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0"]);
|
|
343
|
+
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0"]);
|
|
329
344
|
});
|
|
@@ -344,6 +344,25 @@ function assertStaleConsentBinding(outcome: Record<string, unknown>, binding: un
|
|
|
344
344
|
assert.equal(outcome.next_action, "restart-for-fresh-consent");
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
test("synthetic default-ON status reaches review consent without changing RDD mode", async (t) => {
|
|
348
|
+
const cwd = repository(t);
|
|
349
|
+
const fixture = consentNative(cwd);
|
|
350
|
+
const operations: string[] = [];
|
|
351
|
+
fixture.native.reviewMode = async ({ operation }) => {
|
|
352
|
+
operations.push(operation);
|
|
353
|
+
return {
|
|
354
|
+
operation: "status",
|
|
355
|
+
scope: "clone",
|
|
356
|
+
status: { global: "", cloneLocal: "", effective: "on", source: "default" },
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
await beginConsent(parityRuntime(fixture.native), cwd);
|
|
360
|
+
assert.equal(fixture.starts.count, 1, "default ON must not skip native review START");
|
|
361
|
+
assert.ok(operations.length > 0);
|
|
362
|
+
assert.ok(operations.every((operation) => operation === "status"), "automation must only read RDD mode");
|
|
363
|
+
assert.deepEqual(fixture.answers, [], "default ON does not grant candidate consent");
|
|
364
|
+
});
|
|
365
|
+
|
|
347
366
|
test("review-mode gate retains every off-source continuation and fails closed on native errors", async () => {
|
|
348
367
|
for (const [source, expected] of [
|
|
349
368
|
["clone_local", /clear this clone-local override/],
|
|
@@ -142,7 +142,6 @@ test("package verification names the native review runtime boundary and packaged
|
|
|
142
142
|
|
|
143
143
|
test("npm publication is bound to the exact package tag and triggering commit", () => {
|
|
144
144
|
const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8");
|
|
145
|
-
const releaseSkill = readFileSync(join(PACKAGE_ROOT, "skills", "release", "SKILL.md"), "utf8");
|
|
146
145
|
const packageJson = readPackageJson();
|
|
147
146
|
const dispatchBlock = workflow.match(
|
|
148
147
|
/^ {2}workflow_dispatch:\n([\s\S]*?)^\npermissions:/m,
|
|
@@ -189,16 +188,6 @@ test("npm publication is bound to the exact package tag and triggering commit",
|
|
|
189
188
|
);
|
|
190
189
|
assert.match(workflow, /npm publish --provenance --access public/);
|
|
191
190
|
assert.doesNotMatch(workflow, /pnpm publish|--no-git-checks|NODE_AUTH_TOKEN/);
|
|
192
|
-
|
|
193
|
-
assert.match(releaseSkill, /tag="v\$\{version\}"/);
|
|
194
|
-
assert.match(releaseSkill, /release_sha="\$\(git rev-parse 'origin\/main\^\{commit\}'\)"/);
|
|
195
|
-
assert.match(releaseSkill, /git rev-parse "\$\{tag\}\^\{commit\}"/);
|
|
196
|
-
assert.match(releaseSkill, /git fetch --no-tags origin "refs\/tags\/\$\{tag\}"/);
|
|
197
|
-
assert.match(releaseSkill, /gh release create "\$\{tag\}"[\s\S]*--verify-tag/);
|
|
198
|
-
assert.match(releaseSkill, /--ref main/);
|
|
199
|
-
assert.match(releaseSkill, /-f tag="\$\{tag\}"/);
|
|
200
|
-
assert.match(releaseSkill, /trusted OIDC with provenance/);
|
|
201
|
-
assert.doesNotMatch(releaseSkill, /--ref "\$\{tag\}"|-f dist-tag=/);
|
|
202
191
|
});
|
|
203
192
|
|
|
204
193
|
test("Pi delivery relay is absent from the packaged extension", () => {
|
|
@@ -309,20 +298,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
309
298
|
);
|
|
310
299
|
});
|
|
311
300
|
|
|
312
|
-
test("package verification binds the published Gentle AI v3.
|
|
301
|
+
test("package verification binds the published Gentle AI v3.5.0 runtime pin", () => {
|
|
313
302
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
314
303
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
315
304
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
316
305
|
|
|
317
|
-
assert.match(installer, /INSTALLER_VERSION = "3\.
|
|
306
|
+
assert.match(installer, /INSTALLER_VERSION = "3\.5\.0"/);
|
|
318
307
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
319
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
308
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:3y\+Nb7CtgGB3ne3bSNxkzV6j19etkOnGDXIiuv7tMp8="/);
|
|
320
309
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
321
310
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
322
311
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
323
312
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
324
313
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
325
|
-
assert.match(verifier, /v3\.
|
|
314
|
+
assert.match(verifier, /v3\.5\.0/);
|
|
326
315
|
});
|
|
327
316
|
|
|
328
317
|
|
|
@@ -1574,9 +1563,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1574
1563
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1575
1564
|
});
|
|
1576
1565
|
|
|
1577
|
-
test("Gentle Shell v3.
|
|
1566
|
+
test("Gentle Shell v3.4.0 package and runtime stop before publication", () => {
|
|
1578
1567
|
const packageJson = readPackageJson();
|
|
1579
|
-
assert.equal(packageJson.version, "3.
|
|
1568
|
+
assert.equal(packageJson.version, "3.4.0", "the release manifest must remain explicitly pinned to v3.4.0");
|
|
1580
1569
|
assert.equal(
|
|
1581
1570
|
packageJson.scripts?.test,
|
|
1582
1571
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|