gentle-pi 4.3.0 → 4.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 +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { gzipSync } from "node:zlib";
|
|
9
9
|
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
11
|
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
11
12
|
import type {
|
|
12
13
|
ExtensionAPI,
|
|
@@ -15,9 +16,9 @@ import type {
|
|
|
15
16
|
ToolCallEventResult,
|
|
16
17
|
} from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
18
|
-
import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
19
|
+
import { PROFILES_KIND, PROFILES_VERSION, bootstrapProfilesFile, readProfilesFileResult, type AgentProfilesFile } from "../lib/agent-profiles.ts";
|
|
19
20
|
import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
20
|
-
import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
|
|
21
|
+
import type { AgentModelConfig, AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
|
|
21
22
|
type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
|
|
22
23
|
import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
|
|
23
24
|
import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
@@ -367,6 +368,17 @@ test("registered Gentle Review tools preserve result envelopes and preview usefu
|
|
|
367
368
|
interface RoutingConsumerPanel {
|
|
368
369
|
render(width: number): string[];
|
|
369
370
|
handleInput(data: string): void;
|
|
371
|
+
handleMouse?(event: {
|
|
372
|
+
type: "move" | "click" | "press" | "wheel";
|
|
373
|
+
x: number;
|
|
374
|
+
y: number;
|
|
375
|
+
button?: string;
|
|
376
|
+
wheelDelta?: number;
|
|
377
|
+
screenX?: number;
|
|
378
|
+
screenY?: number;
|
|
379
|
+
width?: number;
|
|
380
|
+
height?: number;
|
|
381
|
+
}): { handled?: boolean; render?: boolean } | undefined;
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
@@ -436,6 +448,9 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
436
448
|
// untouched field returns, and undefined is Esc.
|
|
437
449
|
const inputAnswers: Array<string | undefined> = [];
|
|
438
450
|
const inputPrompts: Array<{ title: string; placeholder: string | undefined }> = [];
|
|
451
|
+
// The models screen binds the session profile through the parent session id; a
|
|
452
|
+
// test can point it at another session (or none) without rebuilding the context.
|
|
453
|
+
let sessionId: string | undefined = "session-panel";
|
|
439
454
|
// The orchestrator model is looked up in the registry before switching.
|
|
440
455
|
const registryModels = [
|
|
441
456
|
{ provider: "openai", id: "alpha" },
|
|
@@ -449,7 +464,7 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
449
464
|
hasUI: true,
|
|
450
465
|
// A real Pi session always has an id; the profiles panel resolves the
|
|
451
466
|
// session binding through it, so the fixture models one stable session.
|
|
452
|
-
sessionManager: { getSessionId: () =>
|
|
467
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
453
468
|
modelRegistry: {
|
|
454
469
|
getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
|
|
455
470
|
find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
|
|
@@ -496,11 +511,18 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
496
511
|
onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
|
|
497
512
|
answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
|
|
498
513
|
inputPrompts,
|
|
499
|
-
|
|
514
|
+
setSessionId(next: string | undefined) { sessionId = next; },
|
|
515
|
+
run: (name: string, args = "") => commands.get(name)!.handler(args, ctx),
|
|
500
516
|
};
|
|
501
517
|
}
|
|
502
518
|
|
|
503
|
-
|
|
519
|
+
const RIGHT_KEY = "\x1b[C";
|
|
520
|
+
|
|
521
|
+
function typeInto(panel: { handleInput(data: string): void }, text: string): void {
|
|
522
|
+
for (const character of text) panel.handleInput(character);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
test("models screen saves and clears provider review roles into the profile store without local artifacts", async (t) => {
|
|
504
526
|
const fixture = routingConsumerFixture(t, []);
|
|
505
527
|
const roles = ["review-refuter", "review-validator"];
|
|
506
528
|
assert.deepEqual(
|
|
@@ -509,6 +531,7 @@ test("models saves and clears independent provider review roles without local ar
|
|
|
509
531
|
"requires zero discovered agents; check package-sibling legacy agent directories",
|
|
510
532
|
);
|
|
511
533
|
|
|
534
|
+
const storePath = join(fixture.configHome, "profiles.json");
|
|
512
535
|
const assertNoLocalArtifacts = () => {
|
|
513
536
|
for (const base of [join(fixture.root, ".pi"), fixture.agentHome]) {
|
|
514
537
|
assert.equal(existsSync(join(base, "subagents.json")), false);
|
|
@@ -518,56 +541,63 @@ test("models saves and clears independent provider review roles without local ar
|
|
|
518
541
|
}
|
|
519
542
|
}
|
|
520
543
|
}
|
|
544
|
+
assert.equal(existsSync(fixture.globalPath), false, "routing edits stay in the profile store until an explicit apply");
|
|
521
545
|
};
|
|
546
|
+
|
|
547
|
+
let visits = 0;
|
|
522
548
|
fixture.onInput((panel) => {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
assert.match(initial, /Pi persisted default model/);
|
|
528
|
-
assert.match(initial, /Pi persisted default effort/);
|
|
529
|
-
for (const [index, model] of ["alpha", "beta"].entries()) {
|
|
549
|
+
visits += 1;
|
|
550
|
+
if (visits === 1) {
|
|
551
|
+
// Rows: orchestrator, set-every-agent header, then the two roles.
|
|
530
552
|
panel.handleInput("j");
|
|
531
|
-
panel.handleInput("
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
553
|
+
panel.handleInput("j");
|
|
554
|
+
for (const [index, model] of ["alpha", "beta"].entries()) {
|
|
555
|
+
if (index > 0) panel.handleInput("j");
|
|
556
|
+
panel.handleInput("\r"); // model card for the role
|
|
557
|
+
assert.match(renderComponent(panel), /Pi persisted default model/);
|
|
558
|
+
assert.doesNotMatch(renderComponent(panel), /Inherit active\/default model/);
|
|
559
|
+
typeInto(panel, model);
|
|
560
|
+
panel.handleInput("\r");
|
|
561
|
+
panel.handleInput("e"); // effort strip for the same role
|
|
562
|
+
assert.match(renderComponent(panel), /Pi persisted default effort/);
|
|
563
|
+
for (let step = 0; step <= index; step++) panel.handleInput(RIGHT_KEY);
|
|
564
|
+
panel.handleInput("\r");
|
|
565
|
+
}
|
|
566
|
+
panel.handleInput("\x13"); // save the draft into the profile
|
|
567
|
+
return;
|
|
541
568
|
}
|
|
542
|
-
panel.handleInput("\
|
|
569
|
+
panel.handleInput("\x1b");
|
|
543
570
|
});
|
|
544
|
-
await fixture.run("gentle:
|
|
545
|
-
assert.
|
|
571
|
+
await fixture.run("gentle:profiles");
|
|
572
|
+
assert.equal(visits, 2);
|
|
573
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, {
|
|
546
574
|
"review-refuter": { model: "openai/alpha", thinking: "off" },
|
|
547
575
|
"review-validator": { model: "openai/beta", thinking: "minimal" },
|
|
548
576
|
});
|
|
549
577
|
assertNoLocalArtifacts();
|
|
550
578
|
|
|
579
|
+
// Clearing entries stays in the store, too.
|
|
580
|
+
visits = 0;
|
|
551
581
|
fixture.onInput((panel) => {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
panel.handleInput("\
|
|
582
|
+
visits += 1;
|
|
583
|
+
if (visits === 1) {
|
|
584
|
+
panel.handleInput("j");
|
|
585
|
+
panel.handleInput("j");
|
|
586
|
+
for (const role of roles) {
|
|
587
|
+
if (role !== "review-refuter") panel.handleInput("j");
|
|
588
|
+
panel.handleInput("\r"); // model card; the cursor starts on the current value
|
|
589
|
+
for (let step = 0; step < 10; step++) panel.handleInput("\x1b[A"); // up to the inherit label, first in the list
|
|
590
|
+
assert.match(renderComponent(panel), /▸ Pi persisted default model/);
|
|
591
|
+
panel.handleInput("\r");
|
|
592
|
+
panel.handleInput("e");
|
|
593
|
+
for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D"); // back to inherit, first in the row
|
|
594
|
+
panel.handleInput("\r");
|
|
595
|
+
}
|
|
596
|
+
panel.handleInput("\x13");
|
|
597
|
+
} else panel.handleInput("\x1b");
|
|
568
598
|
});
|
|
569
|
-
await fixture.run("gentle:
|
|
570
|
-
assert.deepEqual(JSON.parse(readFileSync(
|
|
599
|
+
await fixture.run("gentle:profiles");
|
|
600
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, {
|
|
571
601
|
"review-refuter": {},
|
|
572
602
|
"review-validator": {},
|
|
573
603
|
});
|
|
@@ -605,10 +635,22 @@ test("provider review roles skip migration and both projection paths even when d
|
|
|
605
635
|
rolePaths.forEach((path, index) => assert.equal(readFileSync(path, "utf8"), originals[index]));
|
|
606
636
|
for (const path of profilePaths) assert.equal(readFileSync(path, "utf8"), profile);
|
|
607
637
|
};
|
|
608
|
-
|
|
638
|
+
let visits = 0;
|
|
639
|
+
fixture.onInput((panel) => {
|
|
640
|
+
visits += 1;
|
|
641
|
+
if (visits === 1) {
|
|
642
|
+
// Rows: orchestrator, set-every-agent header, then the roles and worker.
|
|
643
|
+
const table = renderComponent(panel);
|
|
644
|
+
for (const role of roles) assert.equal(table.split(role).length - 1, 1);
|
|
645
|
+
assert.match(table, /worker\s+inherit/, "a discoverable agent with no profile entry reads as inherit");
|
|
646
|
+
panel.handleInput("\x1b");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
panel.handleInput("\x1b");
|
|
650
|
+
});
|
|
651
|
+
await fixture.run("gentle:profiles");
|
|
652
|
+
assert.equal(visits, 1);
|
|
609
653
|
assertReservedUnchanged();
|
|
610
|
-
for (const role of roles) assert.equal(fixture.panels[0].split(role).length - 1, 1);
|
|
611
|
-
assert.match(fixture.panels[0], /worker\s+model=inherit, effort=inherit/);
|
|
612
654
|
for (const apply of [applyModelConfig, applyModelConfigAsync]) {
|
|
613
655
|
await apply(fixture.root, assignments);
|
|
614
656
|
assertReservedUnchanged();
|
|
@@ -620,28 +662,23 @@ test("provider review roles skip migration and both projection paths even when d
|
|
|
620
662
|
assert.ok(JSON.parse(readFileSync(profilePaths[0], "utf8")).model_profiles.worker);
|
|
621
663
|
});
|
|
622
664
|
|
|
623
|
-
test("
|
|
665
|
+
test("an invalid saved routing file does not block the models screen", async (t) => {
|
|
624
666
|
const fixture = routingConsumerFixture(t);
|
|
625
667
|
writeFileSync(fixture.projectPath, "[]");
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
assert.
|
|
629
|
-
assert.equal(fixture.panelVisits(), 0);
|
|
668
|
+
fixture.onInput((panel) => panel.handleInput("\x1b"));
|
|
669
|
+
await fixture.run("gentle:profiles");
|
|
670
|
+
assert.equal(fixture.panelVisits(), 1, "Make default discloses the unreadable routing instead");
|
|
630
671
|
});
|
|
631
672
|
|
|
632
|
-
test("export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => {
|
|
673
|
+
test("models export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => {
|
|
633
674
|
const fixture = routingConsumerFixture(t);
|
|
634
|
-
writeFileSync(fixture.projectPath,
|
|
635
|
-
fixture.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
});
|
|
640
|
-
await fixture.run("gentle:models");
|
|
641
|
-
assert.equal(fixture.notifications[0]?.severity, "warning");
|
|
642
|
-
assert.ok(fixture.notifications[0]?.message.includes(`Invalid model config: ${fixture.projectPath}`));
|
|
675
|
+
writeFileSync(fixture.projectPath, "[]");
|
|
676
|
+
await fixture.run("gentle:profiles", "export-routing");
|
|
677
|
+
assert.equal(fixture.panelVisits(), 0, "export is a subcommand: no screen opens");
|
|
678
|
+
const exportWarning = fixture.notifications.find((entry) => entry.severity === "warning");
|
|
679
|
+
assert.equal(exportWarning?.severity, "warning");
|
|
680
|
+
assert.ok(exportWarning?.message.includes(`Invalid model config: ${fixture.projectPath}`));
|
|
643
681
|
assert.equal(existsSync(fixture.exportPath), false);
|
|
644
|
-
assert.equal(existsSync(fixture.configHome), false);
|
|
645
682
|
});
|
|
646
683
|
|
|
647
684
|
test("status reports invalid saved routing path instead of default agent routing", async (t) => {
|
|
@@ -661,15 +698,14 @@ test("models exports missing, normalized project, and global-precedence saved ro
|
|
|
661
698
|
const fixture = routingConsumerFixture(t);
|
|
662
699
|
if (source !== "missing") writeFileSync(fixture.projectPath, '{"worker":" openai/gpt-5 ","ignored":null}');
|
|
663
700
|
if (source === "global") writeMarkdown(fixture.globalPath, '{"worker":{"model":" anthropic/opus ","thinking":"high"}}');
|
|
664
|
-
|
|
665
|
-
await fixture.run("gentle:models");
|
|
701
|
+
await fixture.run("gentle:profiles", "export-routing");
|
|
666
702
|
const agents = source === "missing" ? {} : source === "project"
|
|
667
703
|
? { worker: { model: "openai/gpt-5" } }
|
|
668
704
|
: { worker: { model: "anthropic/opus", thinking: "high" } };
|
|
669
705
|
assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, agents);
|
|
670
|
-
|
|
671
|
-
assert.
|
|
672
|
-
assert.equal(fixture.panelVisits(),
|
|
706
|
+
const exported = fixture.notifications.find((entry) => /exported/.test(entry.message));
|
|
707
|
+
assert.equal(exported?.severity, "info");
|
|
708
|
+
assert.equal(fixture.panelVisits(), 0);
|
|
673
709
|
await fixture.run("gentle:status");
|
|
674
710
|
const report = fixture.notifications.at(-1)!.message;
|
|
675
711
|
assert.ok(report.includes(`Saved model routing: ${source === "missing" ? "missing" : "valid"}`));
|
|
@@ -677,27 +713,25 @@ test("models exports missing, normalized project, and global-precedence saved ro
|
|
|
677
713
|
const expectedRouting = source === "missing" ? "inherit, effort=inherit"
|
|
678
714
|
: source === "project" ? "openai/gpt-5, effort=inherit" : "anthropic/opus, effort=high";
|
|
679
715
|
assert.ok(report.includes(`worker: model=${expectedRouting}`), report);
|
|
680
|
-
assert.ok(fixture.panels[0].includes(`model=${expectedRouting}`), fixture.panels[0]);
|
|
681
716
|
});
|
|
682
717
|
}
|
|
683
718
|
});
|
|
684
719
|
|
|
685
720
|
test("invalid global routing overrides valid project in models, status, and export re-read", async (t) => {
|
|
686
721
|
for (const atExport of [false, true]) {
|
|
687
|
-
await t.test(atExport ? "
|
|
722
|
+
await t.test(atExport ? "export" : "screen", async (t) => {
|
|
688
723
|
const fixture = routingConsumerFixture(t);
|
|
689
724
|
writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}');
|
|
690
|
-
|
|
691
|
-
fixture.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
assert.
|
|
700
|
-
assert.equal(fixture.panelVisits(), atExport ? 2 : 0);
|
|
725
|
+
writeMarkdown(fixture.globalPath, "[]");
|
|
726
|
+
fixture.onInput((panel) => panel.handleInput("\x1b"));
|
|
727
|
+
await fixture.run("gentle:profiles", atExport ? "export-routing" : "");
|
|
728
|
+
if (atExport) {
|
|
729
|
+
const routingWarning = fixture.notifications.find((entry) => entry.severity === "warning");
|
|
730
|
+
assert.equal(routingWarning?.severity, "warning");
|
|
731
|
+
assert.ok(routingWarning?.message.includes(fixture.globalPath));
|
|
732
|
+
assert.match(routingWarning!.message, /export failed/);
|
|
733
|
+
}
|
|
734
|
+
assert.equal(fixture.panelVisits(), atExport ? 0 : 1, "export opens no screen; the screen opens and Make default discloses the unreadable routing");
|
|
701
735
|
assert.equal(existsSync(fixture.exportPath), false);
|
|
702
736
|
await fixture.run("gentle:status");
|
|
703
737
|
const report = fixture.notifications.at(-1)!;
|
|
@@ -1264,64 +1298,252 @@ test("per-JD-agent model assignment keeps judge-a and judge-b profiles divergent
|
|
|
1264
1298
|
);
|
|
1265
1299
|
});
|
|
1266
1300
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1301
|
+
// ---------------------------------------------------------------------------
|
|
1302
|
+
// Models screen (/gentle:profiles): one table, profiles as tabs.
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
|
|
1305
|
+
const SCREEN_AGENTS = ["gentle-ai-explore", "gentle-ai-verify", "gentle-ai-worker"];
|
|
1306
|
+
const SCREEN_MODELS = ["nan/deepseek-v4-flash", "nan/glm5.3", "openai/gpt-5.5"];
|
|
1307
|
+
|
|
1308
|
+
function screenConfig(): AgentModelConfig {
|
|
1309
|
+
const config: AgentModelConfig = { orchestrator: { model: "openai-codex/gpt-6.1-sol", thinking: "max" } };
|
|
1310
|
+
for (const agent of SCREEN_AGENTS) config[agent] = { model: "nan/deepseek-v4-flash", thinking: "high" };
|
|
1311
|
+
return config;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function screenFile(): AgentProfilesFile {
|
|
1315
|
+
const file = bootstrapProfilesFile(screenConfig());
|
|
1316
|
+
return { ...file, profiles: { ...file.profiles, nan: { orchestrator: { model: "nan/glm5.3", thinking: "high" } } }, active: "current" };
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
type ScreenEvent = { type: "move" | "click" | "wheel"; x: number; y: number; wheelDelta?: number };
|
|
1320
|
+
|
|
1321
|
+
function renderScreen(
|
|
1322
|
+
inputs: string[] = [],
|
|
1323
|
+
mouse: ScreenEvent[] = [],
|
|
1324
|
+
results: unknown[] = [],
|
|
1325
|
+
options: { width?: number; rows?: number; theme?: Theme; thinkingSupport?: Record<string, ThinkingLevel[]>; notices?: string[] } = {},
|
|
1326
|
+
): string[] {
|
|
1327
|
+
return __testing.routingTablePanel(
|
|
1328
|
+
screenConfig(),
|
|
1329
|
+
SCREEN_MODELS,
|
|
1330
|
+
SCREEN_AGENTS,
|
|
1331
|
+
options.width ?? 100,
|
|
1332
|
+
options.theme,
|
|
1333
|
+
options.rows ?? 24,
|
|
1334
|
+
inputs,
|
|
1335
|
+
mouse,
|
|
1336
|
+
results as never[],
|
|
1337
|
+
{ file: screenFile(), sessionBoundName: "current", thinkingSupport: options.thinkingSupport, notices: options.notices },
|
|
1273
1338
|
);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/** Where a piece of text is on a rendered screen, as pointer coordinates. */
|
|
1342
|
+
function screenAt(lines: string[], needle: string): { x: number; y: number } {
|
|
1343
|
+
const plain = lines.map(stripAnsi);
|
|
1344
|
+
const y = plain.findIndex((line) => line.includes(needle));
|
|
1345
|
+
assert.ok(y >= 0, `not on screen: ${needle}`);
|
|
1346
|
+
return { x: plain[y].indexOf(needle) + 1, y };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
test("models screen render does not auto-apply the Gentle theme and sanitizes agent labels", () => {
|
|
1350
|
+
const lines = __testing.routingTablePanel({}, ["openai/gpt-5.5"], ["safe-agent\x1b[31m"], 72);
|
|
1274
1351
|
const rendered = lines.join("\n");
|
|
1275
1352
|
const plain = stripAnsi(rendered);
|
|
1276
|
-
|
|
1277
1353
|
assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;71;85;105m/);
|
|
1278
1354
|
assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;125;211;252m/);
|
|
1279
|
-
assert.match(plain, /
|
|
1280
|
-
assert.match(plain, /safe-agent\s+
|
|
1355
|
+
assert.match(plain, /current/);
|
|
1356
|
+
assert.match(plain, /safe-agent\s+inherit\s+inherit/);
|
|
1281
1357
|
assert.doesNotMatch(plain, /\[31m/);
|
|
1282
1358
|
});
|
|
1283
1359
|
|
|
1284
|
-
test("
|
|
1285
|
-
const lines =
|
|
1360
|
+
test("models screen fills the terminal height and keeps every line inside the frame", () => {
|
|
1361
|
+
const lines = renderScreen([], [], [], { width: 72, rows: 40 });
|
|
1286
1362
|
assert.equal(lines.length, 40);
|
|
1287
1363
|
const plain = lines.map(stripAnsi);
|
|
1288
1364
|
assert.match(plain[0] ?? "", /^╭─+╮$/);
|
|
1289
1365
|
assert.match(plain[39] ?? "", /^╰─+╯$/);
|
|
1290
|
-
for (const line of plain) assert.equal(line
|
|
1366
|
+
for (const line of plain) assert.equal(visibleWidth(line), 72);
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("models screen shows profiles as tabs with their scope, the orchestrator first, and three buttons", () => {
|
|
1370
|
+
const plain = renderScreen().map(stripAnsi);
|
|
1371
|
+
assert.match(plain[1] ?? "", /current ◉★ ▾\s+nan\s+\+ New/, "tabs carry ◉ session and ★ default; ▾ opens the profile menu");
|
|
1372
|
+
assert.match(plain[3] ?? "", /current\s+Used in this session\. Default for new sessions\./);
|
|
1373
|
+
assert.match(plain[5] ?? "", /▸ Orchestrator\s+openai-codex\/gpt-6\.1-sol\s+max/);
|
|
1374
|
+
assert.match(plain.join("\n"), /Agent\s+Model ▾\s+Effort ▾\s+set every agent/);
|
|
1375
|
+
assert.match(plain.join("\n"), /\[ Use in this session \] \[ Make default \] \[ Save \]\s+Saved/);
|
|
1376
|
+
assert.match(plain.join("\n"), /←→ profile {2}↑↓ agent {2}enter model {2}e effort {2}s save {2}u use {2}d default {2}n new {2}m menu {2}esc close/);
|
|
1377
|
+
assert.doesNotMatch(plain.join("\n"), /⋯/, "no ellipsis-like glyph: it reads as cut text");
|
|
1291
1378
|
});
|
|
1292
1379
|
|
|
1293
|
-
test("
|
|
1380
|
+
test("models screen agent rows grow with the terminal height", () => {
|
|
1294
1381
|
const agents = Array.from({ length: 40 }, (_, i) => `agent-${String(i).padStart(2, "0")}`);
|
|
1295
1382
|
const models = Array.from({ length: 60 }, (_, i) => `provider/model-${String(i).padStart(2, "0")}`);
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
assert.equal(
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1383
|
+
const table = __testing.routingTablePanel({}, models, agents, 100, undefined, 40).map(stripAnsi);
|
|
1384
|
+
assert.equal(table.length, 40);
|
|
1385
|
+
// 40 rows minus the frame, 12 fixed body rows, and two scroll indicators.
|
|
1386
|
+
assert.equal(table.filter((line) => /agent-\d\d\s/.test(line)).length, 24);
|
|
1387
|
+
assert.ok(table.some((line) => /↓ \d+ more/.test(line)));
|
|
1388
|
+
const card = __testing.routingTablePanel({}, models, agents, 100, undefined, 40, ["\r"]).map(stripAnsi);
|
|
1389
|
+
// The model card windows its options to the terminal height minus its chrome.
|
|
1390
|
+
assert.equal(card.filter((line) => /provider\/model-\d\d/.test(line)).length, 28);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
test("models screen uses the Pi-provided theme when supplied", () => {
|
|
1394
|
+
const theme = { fg: (_color: string, text: string) => `\x1b[35m${text}\x1b[39m`, bg: (_color: string, text: string) => text } as unknown as Theme;
|
|
1395
|
+
assert.match(renderScreen([], [], [], { theme }).join("\n"), /\x1b\[35m/);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test("models screen pointer: hover uses selectedBg, and one click runs exactly one action", () => {
|
|
1399
|
+
const base = renderScreen();
|
|
1400
|
+
const hoverTheme = { fg: (_color: string, text: string) => text, bg: (color: string, text: string) => `<${color}>${text}</${color}>` } as unknown as Theme;
|
|
1401
|
+
const worker = screenAt(base, "gentle-ai-worker");
|
|
1402
|
+
const hovered = renderScreen([], [{ type: "move", ...worker }], [], { theme: hoverTheme }).join("\n");
|
|
1403
|
+
assert.match(hovered, /<selectedBg>\s*gentle-ai-worker/, "row hover fills with the selection background");
|
|
1404
|
+
assert.doesNotMatch(hovered, /toolPendingBg/, "the pending background is a shade off the terminal background");
|
|
1405
|
+
|
|
1406
|
+
const model = screenAt(base, "nan/deepseek-v4-flash");
|
|
1407
|
+
const card = renderScreen([], [{ type: "click", x: model.x, y: worker.y }]).map(stripAnsi);
|
|
1408
|
+
assert.ok(card.some((line) => line.includes("Model for gentle-ai-worker")), "a click on a model opens that agent's card");
|
|
1409
|
+
assert.match(card[1] ?? "", /current ◉★ ▾\s+nan/, "the screen stays behind the card");
|
|
1410
|
+
const option = screenAt(card, "openai/gpt-5.5");
|
|
1411
|
+
const picked = renderScreen([], [{ type: "click", x: model.x, y: worker.y }, { type: "click", ...option }]).map(stripAnsi);
|
|
1412
|
+
assert.ok(picked.some((line) => /gentle-ai-worker\s+openai\/gpt-5\.5/.test(line)));
|
|
1413
|
+
assert.ok(picked.some((line) => /gentle-ai-explore\s+nan\/deepseek-v4-flash/.test(line)), "other agents are untouched");
|
|
1414
|
+
assert.ok(picked.some((line) => line.includes("● 1 unsaved")), "press and click of one click change one value once");
|
|
1415
|
+
|
|
1416
|
+
const effort = screenAt(base, "high");
|
|
1417
|
+
const strip = renderScreen([], [{ type: "click", x: effort.x, y: worker.y }]);
|
|
1418
|
+
const chip = screenAt(strip, " low ");
|
|
1419
|
+
const efforted = renderScreen([], [{ type: "click", x: effort.x, y: worker.y }, { type: "click", ...chip }]).map(stripAnsi);
|
|
1420
|
+
assert.ok(efforted.some((line) => /gentle-ai-worker\s+nan\/deepseek-v4-flash\s+low/.test(line)));
|
|
1421
|
+
|
|
1422
|
+
const header = screenAt(base, "Model ▾");
|
|
1423
|
+
const all = renderScreen([], [{ type: "click", ...header }]);
|
|
1424
|
+
const glm = screenAt(all, "nan/glm5.3");
|
|
1425
|
+
const allSet = renderScreen([], [{ type: "click", ...header }, { type: "click", ...glm }]).map(stripAnsi);
|
|
1426
|
+
assert.equal(allSet.filter((line) => /gentle-ai-\w+\s+nan\/glm5\.3\s/.test(line)).length, 3, "Model ▾ sets every agent");
|
|
1427
|
+
assert.ok(allSet.some((line) => /Orchestrator\s+openai-codex/.test(line)), "and never the orchestrator");
|
|
1428
|
+
|
|
1429
|
+
const outside = renderScreen([], [{ type: "click", x: model.x, y: worker.y }, { type: "click", x: 3, y: 21 }]).map(stripAnsi);
|
|
1430
|
+
assert.ok(!outside.some((line) => line.includes("Model for")), "a click outside a card closes it");
|
|
1431
|
+
|
|
1432
|
+
const results: Array<{ type: string; name?: string; saveFirst?: AgentModelConfig }> = [];
|
|
1433
|
+
renderScreen([], [{ type: "click", ...screenAt(base, "[ Use in this session ]") }], results);
|
|
1434
|
+
renderScreen([], [{ type: "click", ...screenAt(base, "[ Make default ]") }], results);
|
|
1435
|
+
renderScreen([], [{ type: "click", ...screenAt(base, " nan ") }], results);
|
|
1436
|
+
assert.deepEqual(results.map((result) => result.type), ["use-session", "apply-global", "load"]);
|
|
1437
|
+
|
|
1438
|
+
const menu = renderScreen([], [{ type: "click", ...screenAt(base, "▾") }]);
|
|
1439
|
+
const renamed: Array<{ type: string }> = [];
|
|
1440
|
+
renderScreen([], [{ type: "click", ...screenAt(base, "▾") }, { type: "click", ...screenAt(menu, "Rename") }], renamed);
|
|
1441
|
+
assert.equal(renamed[0]?.type, "rename");
|
|
1442
|
+
|
|
1443
|
+
const newMenu = renderScreen([], [{ type: "click", ...screenAt(base, "+ New") }]);
|
|
1444
|
+
const created: Array<{ type: string }> = [];
|
|
1445
|
+
renderScreen([], [{ type: "click", ...screenAt(base, "+ New") }, { type: "click", ...screenAt(newMenu, "From what's running now") }], created);
|
|
1446
|
+
assert.deepEqual(created.map((result) => result.type), ["create-live"]);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
test("models screen asks before leaving a profile with unsaved changes", () => {
|
|
1450
|
+
const base = renderScreen();
|
|
1451
|
+
const worker = screenAt(base, "gentle-ai-worker");
|
|
1452
|
+
const model = screenAt(base, "nan/deepseek-v4-flash");
|
|
1453
|
+
const card = renderScreen([], [{ type: "click", x: model.x, y: worker.y }]);
|
|
1454
|
+
const option = screenAt(card, "openai/gpt-5.5");
|
|
1455
|
+
const edit = [{ type: "click" as const, x: model.x, y: worker.y }, { type: "click" as const, ...option }];
|
|
1456
|
+
const confirm = renderScreen([], [...edit, { type: "click", ...screenAt(base, " nan ") }]);
|
|
1457
|
+
assert.ok(confirm.map(stripAnsi).some((line) => line.includes("current has 1 unsaved change.")));
|
|
1458
|
+
for (const [button, expected] of [
|
|
1459
|
+
["[ Save and switch ]", { type: "load", name: "nan", saved: "openai/gpt-5.5" }],
|
|
1460
|
+
["[ Discard ]", { type: "load", name: "nan", saved: undefined }],
|
|
1461
|
+
["[ Stay ]", undefined],
|
|
1462
|
+
] as const) {
|
|
1463
|
+
const results: Array<{ type: string; name?: string; saveFirst?: AgentModelConfig }> = [];
|
|
1464
|
+
renderScreen([], [...edit, { type: "click", ...screenAt(base, " nan ") }, { type: "click", ...screenAt(confirm, button) }], results);
|
|
1465
|
+
if (expected === undefined) {
|
|
1466
|
+
assert.deepEqual(results, [], "Stay keeps the screen and the edits");
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
assert.equal(results[0]?.type, expected.type);
|
|
1470
|
+
assert.equal(results[0]?.name, expected.name);
|
|
1471
|
+
assert.equal(results[0]?.saveFirst?.["gentle-ai-worker"]?.model, expected.saved);
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
test("models screen keys: esc twice discards unsaved edits, and each key means one thing", () => {
|
|
1476
|
+
const armed: unknown[] = [];
|
|
1477
|
+
const lines = renderScreen(["j", "j", "j", "\r", "g", "l", "m", "\r", "\x1b"], [], armed).map(stripAnsi);
|
|
1478
|
+
assert.deepEqual(armed, [], "the first esc with unsaved edits only warns");
|
|
1479
|
+
assert.match(profilesPanelFooter(lines.join("\n")), /Unsaved changes\. Press esc again to close without saving\./);
|
|
1480
|
+
const closed: Array<{ type: string }> = [];
|
|
1481
|
+
renderScreen(["j", "j", "j", "\r", "g", "l", "m", "\r", "\x1b", "\x1b"], [], closed);
|
|
1482
|
+
assert.deepEqual(closed.map((result) => result.type), ["close"]);
|
|
1483
|
+
|
|
1484
|
+
const saved: Array<{ type: string }> = [];
|
|
1485
|
+
renderScreen(["s"], [], saved);
|
|
1486
|
+
assert.deepEqual(saved, [], "s with nothing to save is a no-op");
|
|
1487
|
+
assert.match(profilesPanelFooter(renderScreen(["s"]).map(stripAnsi).join("\n")), /Nothing to save\./);
|
|
1488
|
+
const keyed: Array<{ type: string }> = [];
|
|
1489
|
+
for (const key of ["u", "d", "\x1b[C"]) renderScreen([key], [], keyed);
|
|
1490
|
+
assert.deepEqual(keyed.map((result) => result.type), ["use-session", "apply-global", "load"]);
|
|
1491
|
+
|
|
1492
|
+
// Terminals using the kitty keyboard protocol send letters as escape
|
|
1493
|
+
// sequences: "e" arrives as \x1b[101u and must still open the effort row.
|
|
1494
|
+
assert.ok(renderScreen(["\x1b[101u"]).map(stripAnsi).some((line) => line.includes("Effort for the orchestrator")));
|
|
1495
|
+
const kittyKeyed: Array<{ type: string }> = [];
|
|
1496
|
+
renderScreen(["\x1b[117u"], [], kittyKeyed); // u
|
|
1497
|
+
assert.deepEqual(kittyKeyed.map((result) => result.type), ["use-session"]);
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("models screen fits narrow terminals without cutting text", () => {
|
|
1501
|
+
for (const width of [60, 70, 80, 90]) {
|
|
1502
|
+
for (const keys of [[], ["e"], ["\r"], ["m"], ["n"]]) {
|
|
1503
|
+
const plain = renderScreen(keys, [], [], { width, rows: 24 }).map(stripAnsi);
|
|
1504
|
+
assert.equal(plain.filter((line) => visibleWidth(line) !== width).length, 0, `${width} ${keys}`);
|
|
1505
|
+
const cut = plain.filter((line) => line.replace("Custom model id…", "").includes("…") && !/openai-codex|deepseek|glm/.test(line));
|
|
1506
|
+
assert.deepEqual(cut, [], `${width} ${keys}`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
const narrow = renderScreen([], [], [], { width: 60 }).map(stripAnsi).join("\n");
|
|
1510
|
+
assert.match(narrow, /\[ Use here \] \[ Default \] \[ Save \]/, "short labels when the full ones do not fit");
|
|
1511
|
+
});
|
|
1318
1512
|
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1513
|
+
test("models screen offers only the effort levels each model supports and shows where Pi lands the rest", async () => {
|
|
1514
|
+
const thinkingSupport = {
|
|
1515
|
+
"nan/deepseek-v4-flash": ["off", "minimal", "low", "medium", "high"],
|
|
1516
|
+
"openai-codex/gpt-6.1-sol": ["off", "minimal", "low", "medium", "high", "xhigh"],
|
|
1517
|
+
} satisfies Record<string, ThinkingLevel[]>;
|
|
1518
|
+
const table = renderScreen([], [], [], { thinkingSupport }).map(stripAnsi);
|
|
1519
|
+
assert.ok(table.some((line) => /Orchestrator\s+openai-codex\/gpt-6\.1-sol\s+max → xhigh/.test(line)));
|
|
1520
|
+
const card = renderScreen(["j", "j", "e"], [], [], { thinkingSupport }).map(stripAnsi);
|
|
1521
|
+
assert.ok(card.some((line) => line.includes("Levels nan/deepseek-v4-flash supports.")));
|
|
1522
|
+
const chips = card.find((line) => line.includes("inherit") && line.includes("off")) ?? "";
|
|
1523
|
+
assert.match(chips, /\[high\]/);
|
|
1524
|
+
assert.doesNotMatch(chips, /xhigh|max/);
|
|
1525
|
+
|
|
1526
|
+
const ctx = { modelRegistry: { getAvailable: async () => [
|
|
1527
|
+
{ provider: "openai", id: "alpha" },
|
|
1528
|
+
{ provider: "nan", id: "chat", reasoning: false },
|
|
1529
|
+
{ provider: "nan", id: "think", reasoning: true },
|
|
1530
|
+
{ provider: "openai", id: "deep", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: null } },
|
|
1531
|
+
] } } as unknown as ExtensionContext;
|
|
1532
|
+
const support = await __testing.piThinkingSupport(ctx);
|
|
1533
|
+
assert.equal(support["openai/alpha"], undefined, "no reasoning flag: unknown, every level offered");
|
|
1534
|
+
assert.deepEqual(support["nan/chat"], ["off"]);
|
|
1535
|
+
assert.deepEqual(support["nan/think"], ["off", "minimal", "low", "medium", "high"]);
|
|
1536
|
+
assert.deepEqual(support["openai/deep"], ["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
1537
|
+
});
|
|
1322
1538
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1539
|
+
test("models screen lists configuration problems under the profile name without cutting them", () => {
|
|
1540
|
+
const notices = [
|
|
1541
|
+
"The local pin file /home/user/project/.git/gentle-ai/profile-pin.json is not a valid pin and is ignored. Fix or remove it.",
|
|
1542
|
+
'No agent matches "sdd-design" in this profile, so it is ignored at launch.',
|
|
1543
|
+
];
|
|
1544
|
+
const plain = renderScreen([], [], [], { width: 80, notices }).map(stripAnsi);
|
|
1545
|
+
assert.ok(plain.some((line) => line.includes('⚠ No agent matches "sdd-design"')));
|
|
1546
|
+
assert.ok(!plain.some((line) => line.includes("…")));
|
|
1325
1547
|
});
|
|
1326
1548
|
|
|
1327
1549
|
test("delivery commands bypass RDD under every mode outcome while command safety remains independent", async () => {
|
|
@@ -2019,18 +2241,59 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
|
|
|
2019
2241
|
// /gentle:profiles reopens its panel after actions that finish the interaction,
|
|
2020
2242
|
// so a test that applies once must confirm on the first visit and close on the
|
|
2021
2243
|
// next, or the panel and the action loop feed each other forever.
|
|
2022
|
-
|
|
2023
|
-
|
|
2244
|
+
type ScreenPanel = { handleInput(data: string): void; render(width: number): string[] };
|
|
2245
|
+
|
|
2246
|
+
/** The profile the models screen shows, read from the line under the tabs. */
|
|
2247
|
+
function shownProfile(panel: ScreenPanel): string {
|
|
2248
|
+
const line = stripAnsi(renderComponent(panel)).split("\n")[3] ?? "";
|
|
2249
|
+
return line.replace(/^│\s*/, "").split(/\s{3}|\s*│/)[0]?.trim() ?? "";
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/** The profile names on the tab line, in order. */
|
|
2253
|
+
function tabNames(panel: ScreenPanel): string[] {
|
|
2254
|
+
const line = stripAnsi(renderComponent(panel)).split("\n")[1] ?? "";
|
|
2255
|
+
return line
|
|
2256
|
+
.replace(/^│/, "")
|
|
2257
|
+
.replace(/│\s*$/, "")
|
|
2258
|
+
.split(/\s{2,}/)
|
|
2259
|
+
.map((token) => token.trim())
|
|
2260
|
+
.filter((token) => token !== "" && token !== "+ New" && token !== "‹" && token !== "›")
|
|
2261
|
+
.map((token) => token.split(" ")[0] ?? "");
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* Drives the models screen to a profile (a clean tab switch redraws the open
|
|
2266
|
+
* screen in place), presses `key` on it, and closes the screen on every later
|
|
2267
|
+
* visit. Without a name it acts on the first tab, the profile the old list
|
|
2268
|
+
* opened on.
|
|
2269
|
+
*/
|
|
2270
|
+
function actOnProfile(
|
|
2271
|
+
fixture: { onInput(action: (panel: ScreenPanel) => void): void },
|
|
2272
|
+
key: string | readonly string[],
|
|
2273
|
+
name?: string,
|
|
2024
2274
|
): void {
|
|
2025
|
-
let
|
|
2026
|
-
// gentle-shell#1064: Enter now binds the parent session, so the legacy global
|
|
2027
|
-
// apply these tests exercise is the explicit `a` key.
|
|
2275
|
+
let acted = false;
|
|
2028
2276
|
fixture.onInput((panel) => {
|
|
2029
|
-
|
|
2030
|
-
|
|
2277
|
+
if (acted) {
|
|
2278
|
+
panel.handleInput("\x1b");
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
const tabs = tabNames(panel);
|
|
2282
|
+
const target = name ?? tabs[0];
|
|
2283
|
+
for (let step = 0; step < tabs.length && shownProfile(panel) !== target; step++) {
|
|
2284
|
+
panel.handleInput(tabs.indexOf(target) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C");
|
|
2285
|
+
}
|
|
2286
|
+
assert.equal(shownProfile(panel), target);
|
|
2287
|
+
acted = true;
|
|
2288
|
+
for (const data of typeof key === "string" ? [key] : key) panel.handleInput(data);
|
|
2031
2289
|
});
|
|
2032
2290
|
}
|
|
2033
2291
|
|
|
2292
|
+
/** The guarded global apply: Make default (`d`) on a profile, then close. */
|
|
2293
|
+
function applyOnce(fixture: { onInput(action: (panel: ScreenPanel) => void): void }, name?: string): void {
|
|
2294
|
+
actOnProfile(fixture, "d", name);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2034
2297
|
function profilesStoreFixture(t: test.TestContext) {
|
|
2035
2298
|
const fixture = routingConsumerFixture(t, ["worker"]);
|
|
2036
2299
|
const storePath = join(fixture.configHome, "profiles.json");
|
|
@@ -2803,7 +3066,7 @@ test("applying a populated profile with explicit confirmation applies the routin
|
|
|
2803
3066
|
test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
|
|
2804
3067
|
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2805
3068
|
writeSettings();
|
|
2806
|
-
// Routing materialized earlier (a previous profile,
|
|
3069
|
+
// Routing materialized earlier (a previous profile, the models screen, or a
|
|
2807
3070
|
// migration) for an agent the new profile does not mention.
|
|
2808
3071
|
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2809
3072
|
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
|
|
@@ -2855,7 +3118,7 @@ test("a failed apply restores the previous profile's routing with the same repla
|
|
|
2855
3118
|
assert.equal(store.active, "old");
|
|
2856
3119
|
});
|
|
2857
3120
|
|
|
2858
|
-
test("s
|
|
3121
|
+
test("From what's running now creates a profile from the routing in effect without applying it", async (t) => {
|
|
2859
3122
|
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2860
3123
|
writeSettings();
|
|
2861
3124
|
mkdirSync(fixture.configHome, { recursive: true });
|
|
@@ -2869,83 +3132,237 @@ test("s snapshots current routing in place without applying or reopening the pro
|
|
|
2869
3132
|
worker: readFileSync(workerPath, "utf8"),
|
|
2870
3133
|
settings: readFileSync(settingsPath, "utf8"),
|
|
2871
3134
|
};
|
|
2872
|
-
writeStore({
|
|
2873
|
-
|
|
2874
|
-
"z-active": { worker: { model: "openai/beta" } },
|
|
2875
|
-
}, "z-active");
|
|
2876
|
-
|
|
2877
|
-
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2878
|
-
fixture.onInput((panel) => {
|
|
2879
|
-
if (firstPanel === undefined) {
|
|
2880
|
-
firstPanel = panel;
|
|
2881
|
-
panel.handleInput("s");
|
|
2882
|
-
panel.handleInput("\x1b");
|
|
2883
|
-
} else {
|
|
2884
|
-
panel.handleInput("\x1b");
|
|
2885
|
-
}
|
|
2886
|
-
});
|
|
2887
|
-
await fixture.run("gentle:profiles");
|
|
3135
|
+
writeStore({ "a-target": {}, "z-active": { worker: { model: "openai/beta" } } }, "z-active");
|
|
3136
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured");
|
|
2888
3137
|
|
|
2889
3138
|
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2890
|
-
assert.deepEqual(store.profiles
|
|
3139
|
+
assert.deepEqual(store.profiles.captured, {
|
|
2891
3140
|
worker: { model: "openai/alpha", thinking: "minimal" },
|
|
2892
3141
|
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2893
3142
|
});
|
|
3143
|
+
assert.deepEqual(store.profiles["a-target"], {}, "no existing profile is overwritten");
|
|
2894
3144
|
assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
|
|
2895
3145
|
assert.equal(store.active, "z-active");
|
|
2896
|
-
assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
|
|
2897
3146
|
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
|
|
2898
3147
|
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
|
|
2899
3148
|
assert.equal(readFileSync(workerPath, "utf8"), before.worker);
|
|
2900
3149
|
assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
|
|
2901
|
-
assert.equal(
|
|
2902
|
-
assert.
|
|
2903
|
-
const targetRow = renderComponent(firstPanel!).split("\n");
|
|
2904
|
-
const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
|
|
2905
|
-
assert.ok(targetIndex >= 0, "selected profile remains in the list");
|
|
2906
|
-
assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
|
|
2907
|
-
assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
|
|
3150
|
+
assert.equal(selectedProfileTitle(reopened), "captured", "the screen shows the new profile");
|
|
3151
|
+
assert.match(profilesPanelFooter(reopened), /Created "captured" from what's running now\./);
|
|
2908
3152
|
});
|
|
2909
3153
|
|
|
2910
|
-
test("s
|
|
3154
|
+
test("From what's running now captures the live session's orchestrator over the settings defaults", async (t) => {
|
|
2911
3155
|
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2912
3156
|
writeSettings();
|
|
2913
|
-
writeStore({
|
|
2914
|
-
"a-target": {},
|
|
2915
|
-
"z-active": { worker: { model: "openai/beta" } },
|
|
2916
|
-
}, "z-active");
|
|
3157
|
+
writeStore({ "z-active": { worker: { model: "openai/beta" } } }, "z-active");
|
|
2917
3158
|
// The session runs live on openai/omega at low effort while settings.json still
|
|
2918
|
-
// defaults to nan/deepseek-v4-flash at high: the
|
|
2919
|
-
//
|
|
3159
|
+
// defaults to nan/deepseek-v4-flash at high: the capture must be the session the
|
|
3160
|
+
// user is actually in, not the default new sessions would get.
|
|
2920
3161
|
fixture.setLiveModel("openai", "omega", "low");
|
|
2921
3162
|
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2922
|
-
fixture.
|
|
2923
|
-
panel.handleInput("s");
|
|
2924
|
-
panel.handleInput("\x1b");
|
|
2925
|
-
});
|
|
2926
|
-
await fixture.run("gentle:profiles");
|
|
3163
|
+
await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured");
|
|
2927
3164
|
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2928
|
-
assert.deepEqual(
|
|
2929
|
-
store.profiles["a-target"].orchestrator,
|
|
2930
|
-
{ model: "openai/omega", thinking: "low" },
|
|
2931
|
-
"the orchestrator entry comes from the live session, not settings.json",
|
|
2932
|
-
);
|
|
3165
|
+
assert.deepEqual(store.profiles.captured.orchestrator, { model: "openai/omega", thinking: "low" });
|
|
2933
3166
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings are untouched");
|
|
2934
3167
|
});
|
|
2935
3168
|
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3169
|
+
/** Moves the cursor to the orchestrator row, the first row of the table. */
|
|
3170
|
+
function chooseOrchestrator(panel: RoutingConsumerPanel): void {
|
|
3171
|
+
for (let step = 0; step < 4; step++) panel.handleInput("\x1b[A");
|
|
3172
|
+
assert.match(renderComponent(panel), /▸ Orchestrator/);
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
/** Opens the model card on the cursor row, types a search, and picks the first match. */
|
|
3176
|
+
function chooseModel(panel: RoutingConsumerPanel, query: string): void {
|
|
3177
|
+
panel.handleInput("\r");
|
|
3178
|
+
for (const character of query) panel.handleInput(character);
|
|
2942
3179
|
panel.handleInput("\r");
|
|
2943
|
-
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
/** Opens the effort row on the cursor row and picks a level by moving the bracket to it. */
|
|
3183
|
+
function chooseEffort(panel: RoutingConsumerPanel, effort: string): void {
|
|
3184
|
+
panel.handleInput("e");
|
|
3185
|
+
for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D");
|
|
3186
|
+
for (let step = 0; step < 10 && !renderComponent(panel).includes(`[${effort}]`); step++) panel.handleInput("\x1b[C");
|
|
3187
|
+
assert.ok(renderComponent(panel).includes(`[${effort}]`), `effort ${effort} is offered`);
|
|
2944
3188
|
panel.handleInput("\r");
|
|
2945
|
-
panel.handleInput("u");
|
|
2946
3189
|
}
|
|
2947
3190
|
|
|
2948
|
-
|
|
3191
|
+
/** Moves the cursor to an agent row by name. */
|
|
3192
|
+
function chooseAgent(panel: RoutingConsumerPanel, agent: string): void {
|
|
3193
|
+
for (let step = 0; step < 20 && !new RegExp(`▸ ${agent}\\s`).test(renderComponent(panel)); step++) panel.handleInput("j");
|
|
3194
|
+
assert.match(renderComponent(panel), new RegExp(`▸ ${agent}\\s`));
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
test("models screen saves the explicit orchestrator choice into the profile", async (t) => {
|
|
3198
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3199
|
+
// Use in this session binds the fixture session in a process-wide store.
|
|
3200
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3201
|
+
writeSettings();
|
|
3202
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3203
|
+
fixture.setLiveModel("openai", "alpha", "high");
|
|
3204
|
+
writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "max" } } }, "team");
|
|
3205
|
+
let visits = 0;
|
|
3206
|
+
fixture.onInput((panel) => {
|
|
3207
|
+
visits += 1;
|
|
3208
|
+
if (visits === 1) {
|
|
3209
|
+
assert.match(renderComponent(panel), /Orchestrator\s+nan\/glm5\.3\s+max/);
|
|
3210
|
+
chooseOrchestrator(panel);
|
|
3211
|
+
chooseModel(panel, "beta");
|
|
3212
|
+
chooseEffort(panel, "low");
|
|
3213
|
+
chooseAgent(panel, "worker");
|
|
3214
|
+
chooseModel(panel, "alpha");
|
|
3215
|
+
chooseEffort(panel, "high");
|
|
3216
|
+
assert.match(renderComponent(panel), /Orchestrator\s+openai\/beta\s+low/);
|
|
3217
|
+
panel.handleInput("\x13");
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
panel.handleInput("\x1b");
|
|
3221
|
+
});
|
|
3222
|
+
await fixture.run("gentle:profiles");
|
|
3223
|
+
const snapshot = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
|
|
3224
|
+
assert.deepEqual(snapshot.orchestrator, { model: "openai/beta", thinking: "low" });
|
|
3225
|
+
assert.deepEqual(snapshot.worker, { model: "openai/alpha", thinking: "high" });
|
|
3226
|
+
assert.equal(existsSync(fixture.globalPath), false, "Save never writes global routing");
|
|
3227
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "Save never materializes agent routing");
|
|
3228
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3229
|
+
assert.deepEqual(fixture.liveSwitches, [], "saving never switches the live model or effort");
|
|
3230
|
+
assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false);
|
|
3231
|
+
|
|
3232
|
+
// Use in this session still drives the live session.
|
|
3233
|
+
actOnProfile(fixture, "u");
|
|
3234
|
+
await fixture.run("gentle:profiles");
|
|
3235
|
+
assert.deepEqual(fixture.liveSwitches, [
|
|
3236
|
+
{ kind: "model", provider: "openai", id: "beta" },
|
|
3237
|
+
{ kind: "thinking", level: "low" },
|
|
3238
|
+
]);
|
|
3239
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3240
|
+
});
|
|
3241
|
+
|
|
3242
|
+
test("models screen leave untouched removes a stale profile orchestrator and keeps the live model on use", async (t) => {
|
|
3243
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3244
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3245
|
+
writeSettings();
|
|
3246
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3247
|
+
fixture.setLiveModel("openai", "alpha", "low");
|
|
3248
|
+
writeStore({ team: { orchestrator: { model: "openai/beta", thinking: "high" } } }, "team");
|
|
3249
|
+
writeFileSync(fixture.globalPath, JSON.stringify({ orchestrator: { model: "openai/beta", thinking: "high" }, worker: { model: "openai/alpha" } }));
|
|
3250
|
+
const globalBefore = readFileSync(fixture.globalPath, "utf8");
|
|
3251
|
+
let visits = 0;
|
|
3252
|
+
fixture.onInput((panel) => {
|
|
3253
|
+
visits += 1;
|
|
3254
|
+
if (visits === 1) {
|
|
3255
|
+
chooseOrchestrator(panel);
|
|
3256
|
+
chooseModel(panel, "leave untouched");
|
|
3257
|
+
assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/);
|
|
3258
|
+
panel.handleInput("\x13");
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
panel.handleInput("\x1b");
|
|
3262
|
+
});
|
|
3263
|
+
await fixture.run("gentle:profiles");
|
|
3264
|
+
assert.equal(Object.hasOwn(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, "orchestrator"), false);
|
|
3265
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), globalBefore, "Save never writes global routing");
|
|
3266
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3267
|
+
assert.deepEqual(fixture.liveSwitches, []);
|
|
3268
|
+
actOnProfile(fixture, "u");
|
|
3269
|
+
await fixture.run("gentle:profiles");
|
|
3270
|
+
assert.deepEqual(fixture.liveSwitches, [], "a profile without an orchestrator keeps the live model and effort");
|
|
3271
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3272
|
+
});
|
|
3273
|
+
|
|
3274
|
+
for (const [name, keys] of [["ctrl+s", ["\x13"]], ["esc twice", ["\x1b", "\x1b"]]] as const) {
|
|
3275
|
+
test(`models screen ${name} ${name === "ctrl+s" ? "persists the profile without touching global state" : "persists nothing"}`, async (t) => {
|
|
3276
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3277
|
+
writeSettings();
|
|
3278
|
+
fixture.setLiveModel("openai", "alpha", "high");
|
|
3279
|
+
writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" } } }, "team");
|
|
3280
|
+
const storeBefore = readFileSync(storePath, "utf8");
|
|
3281
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3282
|
+
let visits = 0;
|
|
3283
|
+
fixture.onInput((panel) => {
|
|
3284
|
+
visits += 1;
|
|
3285
|
+
if (visits === 1) {
|
|
3286
|
+
chooseOrchestrator(panel);
|
|
3287
|
+
chooseModel(panel, "beta");
|
|
3288
|
+
chooseEffort(panel, "low");
|
|
3289
|
+
for (const key of keys) panel.handleInput(key);
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
panel.handleInput("\x1b");
|
|
3293
|
+
});
|
|
3294
|
+
await fixture.run("gentle:profiles");
|
|
3295
|
+
assert.deepEqual(fixture.liveSwitches, []);
|
|
3296
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3297
|
+
if (name === "ctrl+s") {
|
|
3298
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team.orchestrator, { model: "openai/beta", thinking: "low" });
|
|
3299
|
+
assert.equal(existsSync(fixture.globalPath), false);
|
|
3300
|
+
} else {
|
|
3301
|
+
assert.equal(readFileSync(storePath, "utf8"), storeBefore);
|
|
3302
|
+
assert.equal(visits, 1, "the second esc closes the screen");
|
|
3303
|
+
}
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
test("models screen Model ▾ and Effort ▾ set every agent to inherit and leave the orchestrator alone", async (t) => {
|
|
3308
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3309
|
+
writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/beta", thinking: "low" } } }, "team");
|
|
3310
|
+
let visits = 0;
|
|
3311
|
+
fixture.onInput((panel) => {
|
|
3312
|
+
visits += 1;
|
|
3313
|
+
if (visits === 1) {
|
|
3314
|
+
chooseOrchestrator(panel);
|
|
3315
|
+
panel.handleInput("j"); // the header row: every agent
|
|
3316
|
+
assert.match(renderComponent(panel), /▸ Agent\s+Model ▾/);
|
|
3317
|
+
chooseModel(panel, "inherit");
|
|
3318
|
+
panel.handleInput("e");
|
|
3319
|
+
for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D");
|
|
3320
|
+
panel.handleInput("\r");
|
|
3321
|
+
panel.handleInput("\x13");
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
panel.handleInput("\x1b");
|
|
3325
|
+
});
|
|
3326
|
+
await fixture.run("gentle:profiles");
|
|
3327
|
+
const profile = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
|
|
3328
|
+
assert.deepEqual(profile.worker, {});
|
|
3329
|
+
assert.deepEqual(profile.orchestrator, { model: "openai/alpha", thinking: "high" });
|
|
3330
|
+
});
|
|
3331
|
+
|
|
3332
|
+
test("models screen custom model id: a valid id lands in the draft, an invalid one changes nothing", async (t) => {
|
|
3333
|
+
for (const [answer, expected] of [["openai/custom-1", "openai/custom-1"], ["bad\nmodel: injected", "openai/beta"]] as const) {
|
|
3334
|
+
await t.test(answer.includes("\n") ? "invalid" : "valid", async (t) => {
|
|
3335
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3336
|
+
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3337
|
+
fixture.answerInputs(answer);
|
|
3338
|
+
let visits = 0;
|
|
3339
|
+
fixture.onInput((panel) => {
|
|
3340
|
+
visits += 1;
|
|
3341
|
+
if (visits === 1) {
|
|
3342
|
+
chooseAgent(panel, "worker");
|
|
3343
|
+
panel.handleInput("\r");
|
|
3344
|
+
for (const character of "zzz") panel.handleInput(character); // no catalog match: only Custom model id… is left
|
|
3345
|
+
assert.match(renderComponent(panel), /▸ Custom model id…/);
|
|
3346
|
+
panel.handleInput("\r");
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
if (visits === 2) {
|
|
3350
|
+
assert.match(renderComponent(panel), new RegExp(`worker\\s+${expected.replace("/", "\\/")}`));
|
|
3351
|
+
// A rejected id leaves nothing to save, so the screen just closes.
|
|
3352
|
+
panel.handleInput(expected === "openai/beta" ? "\x1b" : "\x13");
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
panel.handleInput("\x1b");
|
|
3356
|
+
});
|
|
3357
|
+
await fixture.run("gentle:profiles");
|
|
3358
|
+
assert.match(fixture.inputPrompts[0]?.title ?? "", /Custom model id for worker/);
|
|
3359
|
+
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).profiles.team.worker.model, expected);
|
|
3360
|
+
if (expected === "openai/beta") assert.match(fixture.panels[1] ?? "", /Custom model id must be one provider\/model line/);
|
|
3361
|
+
});
|
|
3362
|
+
}
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
test("models screen Save preserves an absent orchestrator and sibling profiles", async (t) => {
|
|
2949
3366
|
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2950
3367
|
writeSettings();
|
|
2951
3368
|
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
@@ -2954,127 +3371,88 @@ test("u saves global routing and captures live session orchestrator instead of d
|
|
|
2954
3371
|
team: { worker: { model: "openai/beta" } },
|
|
2955
3372
|
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
2956
3373
|
}, "team");
|
|
3374
|
+
let visits = 0;
|
|
2957
3375
|
fixture.onInput((panel) => {
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3376
|
+
visits += 1;
|
|
3377
|
+
if (visits === 1) {
|
|
3378
|
+
assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/);
|
|
3379
|
+
chooseAgent(panel, "worker");
|
|
3380
|
+
chooseModel(panel, "alpha");
|
|
3381
|
+
panel.handleInput("\x13");
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
3384
|
+
panel.handleInput("\x1b");
|
|
2961
3385
|
});
|
|
2962
|
-
await fixture.run("gentle:
|
|
2963
|
-
|
|
2964
|
-
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
3386
|
+
await fixture.run("gentle:profiles");
|
|
3387
|
+
assert.equal(existsSync(fixture.globalPath), false, "Save never writes global routing");
|
|
2965
3388
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2966
|
-
assert.deepEqual(store.profiles.team, {
|
|
2967
|
-
|
|
2968
|
-
orchestrator: { model: "openai/alpha", thinking: "low" },
|
|
2969
|
-
});
|
|
3389
|
+
assert.deepEqual(store.profiles.team, { worker: { model: "openai/alpha" } });
|
|
3390
|
+
assert.deepEqual(fixture.liveSwitches, []);
|
|
2970
3391
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "global defaults remain unchanged");
|
|
2971
3392
|
assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
|
|
2972
3393
|
assert.equal(store.active, "team");
|
|
2973
|
-
assert.equal(
|
|
2974
|
-
assert.ok(
|
|
2975
|
-
fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
|
|
2976
|
-
"the global save is still reported",
|
|
2977
|
-
);
|
|
2978
|
-
assert.ok(
|
|
2979
|
-
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
|
|
2980
|
-
`profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2981
|
-
);
|
|
3394
|
+
assert.equal(visits, 2);
|
|
2982
3395
|
});
|
|
2983
3396
|
|
|
2984
|
-
test("
|
|
2985
|
-
const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
|
|
3397
|
+
test("gentle:profiles opens on the pinned profile and Save preserves its stored orchestrator", async (t) => {
|
|
3398
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
|
|
2986
3399
|
writeSettings();
|
|
2987
|
-
|
|
3400
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3401
|
+
fixture.setLiveModel("openai", "alpha", "low");
|
|
2988
3402
|
writeStore({
|
|
2989
3403
|
team: { worker: { model: "openai/beta" } },
|
|
2990
|
-
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
3404
|
+
other: { worker: { model: "openai/beta", thinking: "high" }, orchestrator: { model: "openai/beta", thinking: "high" } },
|
|
2991
3405
|
}, "team");
|
|
2992
3406
|
writePin(localPinPath, "other");
|
|
3407
|
+
let visits = 0;
|
|
2993
3408
|
fixture.onInput((panel) => {
|
|
2994
|
-
|
|
2995
|
-
|
|
3409
|
+
visits += 1;
|
|
3410
|
+
if (visits === 1) {
|
|
3411
|
+
assert.equal(shownProfile(panel), "other", "the pin preselects its profile");
|
|
3412
|
+
assert.match(renderComponent(panel), /Orchestrator\s+openai\/beta\s+high/);
|
|
3413
|
+
chooseAgent(panel, "worker");
|
|
3414
|
+
chooseModel(panel, "alpha");
|
|
3415
|
+
panel.handleInput("\x13");
|
|
3416
|
+
return;
|
|
3417
|
+
}
|
|
3418
|
+
panel.handleInput("\x1b");
|
|
2996
3419
|
});
|
|
2997
|
-
await fixture.run("gentle:
|
|
2998
|
-
|
|
2999
|
-
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
3420
|
+
await fixture.run("gentle:profiles");
|
|
3000
3421
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3001
3422
|
assert.deepEqual(store.profiles.other, {
|
|
3002
|
-
worker: { model: "openai/alpha" },
|
|
3003
|
-
orchestrator: { model: "openai/beta", thinking: "
|
|
3423
|
+
worker: { model: "openai/alpha", thinking: "high" },
|
|
3424
|
+
orchestrator: { model: "openai/beta", thinking: "high" },
|
|
3004
3425
|
});
|
|
3426
|
+
assert.deepEqual(fixture.liveSwitches, []);
|
|
3427
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3005
3428
|
assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
|
|
3006
3429
|
assert.equal(store.active, "team");
|
|
3007
|
-
assert.
|
|
3008
|
-
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
|
|
3009
|
-
`pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
3010
|
-
);
|
|
3430
|
+
assert.equal(visits, 2);
|
|
3011
3431
|
});
|
|
3012
3432
|
|
|
3013
|
-
test("
|
|
3014
|
-
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3015
|
-
writeSettings();
|
|
3016
|
-
writeStore({ team: { worker: { model: "openai/beta" } } });
|
|
3017
|
-
const before = readFileSync(storePath, "utf8");
|
|
3018
|
-
fixture.onInput((panel) => {
|
|
3019
|
-
assert.match(renderComponent(panel), /Current profile: none/);
|
|
3020
|
-
pickWorkerModelThenUpdateProfile(panel);
|
|
3021
|
-
});
|
|
3022
|
-
await fixture.run("gentle:models");
|
|
3023
|
-
|
|
3024
|
-
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
3025
|
-
assert.equal(readFileSync(storePath, "utf8"), before, "the store is untouched");
|
|
3026
|
-
assert.ok(
|
|
3027
|
-
fixture.notifications.some((entry) => entry.severity === "warning" && /no profile is current/.test(entry.message) && /\/gentle:profiles/.test(entry.message)),
|
|
3028
|
-
`missing current profile is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
3029
|
-
);
|
|
3030
|
-
});
|
|
3031
|
-
|
|
3032
|
-
test("u seeds the profiles store the way /gentle:profiles does when it is missing", async (t) => {
|
|
3433
|
+
test("gentle:profiles seeds the profiles store when it is missing", async (t) => {
|
|
3033
3434
|
const { fixture, storePath, writeSettings } = profilesStoreFixture(t);
|
|
3034
3435
|
writeSettings();
|
|
3035
3436
|
assert.equal(existsSync(storePath), false);
|
|
3437
|
+
let visits = 0;
|
|
3036
3438
|
fixture.onInput((panel) => {
|
|
3037
|
-
|
|
3038
|
-
|
|
3439
|
+
visits += 1;
|
|
3440
|
+
if (visits === 1) {
|
|
3441
|
+
assert.equal(shownProfile(panel), "current");
|
|
3442
|
+
assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/, "the seed captures agent routing only");
|
|
3443
|
+
}
|
|
3444
|
+
panel.handleInput("\x1b");
|
|
3039
3445
|
});
|
|
3040
|
-
await fixture.run("gentle:
|
|
3041
|
-
|
|
3446
|
+
await fixture.run("gentle:profiles");
|
|
3042
3447
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3043
3448
|
assert.equal(store.kind, PROFILES_KIND);
|
|
3044
|
-
assert.deepEqual(store.profiles.current, {
|
|
3045
|
-
|
|
3046
|
-
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
3047
|
-
});
|
|
3048
|
-
assert.equal(store.active, "current");
|
|
3449
|
+
assert.deepEqual(store.profiles.current, {});
|
|
3450
|
+
assert.equal(store.active, undefined);
|
|
3049
3451
|
assert.ok(
|
|
3050
3452
|
fixture.notifications.some((entry) => entry.severity === "info" && /seeded the "current" profile/.test(entry.message)),
|
|
3051
3453
|
`seeding is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
3052
3454
|
);
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
test("snapshot feedback keeps both outcomes visible for long profile names at narrow widths", async (t) => {
|
|
3056
|
-
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3057
|
-
const longName = `a${"x".repeat(63)}`;
|
|
3058
|
-
writeStore({ [longName]: {} });
|
|
3059
|
-
let firstPanel: RoutingConsumerPanel | undefined;
|
|
3060
|
-
fixture.onInput((panel) => {
|
|
3061
|
-
if (firstPanel === undefined) {
|
|
3062
|
-
firstPanel = panel;
|
|
3063
|
-
panel.handleInput("s");
|
|
3064
|
-
const success = stripAnsi(panel.render(60).join("\n"));
|
|
3065
|
-
assert.match(success, /Snapshot saved; live routing unchanged\./);
|
|
3066
|
-
rmSync(storePath);
|
|
3067
|
-
mkdirSync(storePath);
|
|
3068
|
-
panel.handleInput("s");
|
|
3069
|
-
panel.handleInput("\x1b");
|
|
3070
|
-
} else {
|
|
3071
|
-
panel.handleInput("\x1b");
|
|
3072
|
-
}
|
|
3073
|
-
});
|
|
3074
|
-
await fixture.run("gentle:profiles");
|
|
3075
|
-
assert.ok(firstPanel);
|
|
3076
|
-
const constrained = stripAnsi(firstPanel!.render(60).join("\n"));
|
|
3077
|
-
assert.match(constrained, /Snapshot failed; live routing unchanged\./);
|
|
3455
|
+
assert.equal(visits, 1);
|
|
3078
3456
|
});
|
|
3079
3457
|
|
|
3080
3458
|
test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
|
|
@@ -3097,10 +3475,8 @@ test("the profiles command seeds and shows the routing the runtime uses when mod
|
|
|
3097
3475
|
helper: { model: "openai/beta" },
|
|
3098
3476
|
});
|
|
3099
3477
|
assert.ok(rendered);
|
|
3100
|
-
assert.match(rendered, /
|
|
3101
|
-
assert.match(rendered, /worker\s+openai\/alpha\s+high/);
|
|
3478
|
+
assert.match(rendered, /worker\s+openai\/alpha\s+high/, "the seeded profile shows the routing in effect");
|
|
3102
3479
|
assert.match(rendered, /helper\s+openai\/beta/);
|
|
3103
|
-
assert.doesNotMatch(rendered, /No routing entries/);
|
|
3104
3480
|
});
|
|
3105
3481
|
|
|
3106
3482
|
for (const reader of ["readEffectiveModelConfig", "readEffectiveModelConfigAsync"] as const) {
|
|
@@ -3134,112 +3510,167 @@ test("effective routing prefers models.json over the materialized stores", (t) =
|
|
|
3134
3510
|
assert.equal(existsSync(join(fixture.root, ".pi", "gentle-ai", "models.json")), false, "reading never writes");
|
|
3135
3511
|
});
|
|
3136
3512
|
|
|
3137
|
-
|
|
3513
|
+
// Issue #2196: a profile key that names no discovered agent was silently
|
|
3514
|
+
// ignored at launch. gentle-pi#2271: provider review roles are routed by key
|
|
3515
|
+
// and never have an agent file, so they are never flagged.
|
|
3516
|
+
test("the models screen flags profile keys no agent matches and never flags provider review roles", async (t) => {
|
|
3138
3517
|
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
3139
3518
|
writeStore({
|
|
3140
3519
|
team: {
|
|
3141
3520
|
orchestrator: { model: "nan/glm5.3", thinking: "high" },
|
|
3142
3521
|
worker: { model: "openai/alpha", thinking: "high" },
|
|
3143
3522
|
"sdd-design": { model: "nan/glm5.3", thinking: "high" },
|
|
3523
|
+
"review-refuter": { model: "nan/glm5.3", thinking: "high" },
|
|
3524
|
+
},
|
|
3525
|
+
}, "team");
|
|
3526
|
+
let rendered = "";
|
|
3527
|
+
fixture.onInput((visited) => {
|
|
3528
|
+
rendered = stripAnsi(renderComponent(visited));
|
|
3529
|
+
visited.handleInput("\x1b");
|
|
3530
|
+
});
|
|
3531
|
+
await fixture.run("gentle:profiles");
|
|
3532
|
+
const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
|
|
3533
|
+
assert.equal(rendered.split("\n").length, 24, "the frame spans the terminal height");
|
|
3534
|
+
assert.match(flat(rendered), /⚠ No agent matches "sdd-design" in this profile, so it is ignored at launch\./);
|
|
3535
|
+
assert.doesNotMatch(flat(rendered), /review-refuter"/, "provider review roles are never flagged");
|
|
3536
|
+
assert.match(rendered, /Orchestrator\s+nan\/glm5\.3\s+high/);
|
|
3537
|
+
assert.match(rendered, /worker\s+openai\/alpha\s+high/);
|
|
3538
|
+
assert.match(rendered, /review-refuter\s+nan\/glm5\.3\s+high/);
|
|
3539
|
+
});
|
|
3540
|
+
|
|
3541
|
+
test("at 60 columns the unmatched-key notice wraps instead of being cut", async (t) => {
|
|
3542
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
3543
|
+
writeStore({
|
|
3544
|
+
team: {
|
|
3545
|
+
worker: { model: "openai/alpha", thinking: "high" },
|
|
3546
|
+
"sdd-design": { model: "nan/glm5.3", thinking: "high" },
|
|
3144
3547
|
},
|
|
3145
3548
|
}, "team");
|
|
3146
|
-
let rendered: string | undefined;
|
|
3147
3549
|
let panel: { render(width: number): string[] } | undefined;
|
|
3148
3550
|
fixture.onInput((visited) => {
|
|
3149
3551
|
panel = visited;
|
|
3150
|
-
rendered = renderComponent(visited);
|
|
3151
3552
|
visited.handleInput("\x1b");
|
|
3152
3553
|
});
|
|
3153
3554
|
await fixture.run("gentle:profiles");
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
const
|
|
3157
|
-
|
|
3158
|
-
assert.
|
|
3159
|
-
assert.match(lines[0], /^╭/);
|
|
3160
|
-
assert.match(lines.at(-1)!, /^╰/);
|
|
3161
|
-
const text = rendered;
|
|
3162
|
-
// Routing is listed one agent per line, aligned in columns, never collapsed
|
|
3163
|
-
// into "N agents → model: a, b, …" summaries.
|
|
3164
|
-
assert.match(text, /Profile routing/);
|
|
3165
|
-
assert.match(text, /Current routing \(effective\)/);
|
|
3166
|
-
assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
|
|
3167
|
-
assert.match(text, /worker\s+openai\/alpha\s+high/);
|
|
3168
|
-
assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
|
|
3169
|
-
assert.doesNotMatch(text, /agents? → /);
|
|
3170
|
-
|
|
3171
|
-
// A taller terminal renders a taller frame with the same content.
|
|
3172
|
-
fixture.tui.terminal.rows = 40;
|
|
3173
|
-
const taller = renderComponent(panel);
|
|
3174
|
-
assert.equal(taller.split("\n").length, 40);
|
|
3175
|
-
|
|
3176
|
-
// A short terminal clamps instead of crashing, and keeps both borders.
|
|
3177
|
-
fixture.tui.terminal.rows = 9;
|
|
3178
|
-
const short = stripAnsi(panel.render(120).map((line) => line.replace(/[ \t]+$/g, "")).join("\n"));
|
|
3179
|
-
const shortLines = short.split("\n");
|
|
3180
|
-
assert.equal(shortLines.length, 9);
|
|
3181
|
-
assert.match(shortLines[0], /^╭/);
|
|
3182
|
-
assert.match(shortLines.at(-1)!, /^╰/);
|
|
3555
|
+
assert.ok(panel);
|
|
3556
|
+
const narrow = stripAnsi(panel.render(60).join("\n"));
|
|
3557
|
+
const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
|
|
3558
|
+
assert.match(flat(narrow), /⚠ No agent matches "sdd-design" in this profile, so it is ignored at launch\./);
|
|
3559
|
+
assert.ok(!narrow.split("\n").some((line) => line.includes("…")), "nothing is cut");
|
|
3183
3560
|
});
|
|
3184
3561
|
|
|
3185
|
-
|
|
3562
|
+
// Issue #2400: a subagents.json model_profiles key that matches no discovered
|
|
3563
|
+
// agent and suggests none is ignored at launch; the screen names it. A key with
|
|
3564
|
+
// a suggestion is left to the launch warning.
|
|
3565
|
+
test("the models screen names subagents.json keys that match no agent and suggest none", async (t) => {
|
|
3186
3566
|
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3567
|
+
writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
|
|
3568
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
|
|
3569
|
+
model_profiles: {
|
|
3570
|
+
worker: { model: "openai/alpha" },
|
|
3571
|
+
explor: { model: "openai/beta" },
|
|
3572
|
+
"sdd-orchestratr-x": { model: "openai/beta" },
|
|
3573
|
+
"gentle-ai-worker": { model: "openai/beta" },
|
|
3574
|
+
},
|
|
3575
|
+
}, null, 2)}\n`);
|
|
3576
|
+
let panel: { render(width: number): string[] } | undefined;
|
|
3193
3577
|
fixture.onInput((visited) => {
|
|
3194
3578
|
panel = visited;
|
|
3195
3579
|
visited.handleInput("\x1b");
|
|
3196
3580
|
});
|
|
3581
|
+
fixture.tui.terminal.rows = 40;
|
|
3197
3582
|
await fixture.run("gentle:profiles");
|
|
3198
|
-
assert.ok(panel
|
|
3199
|
-
const
|
|
3200
|
-
const
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3583
|
+
assert.ok(panel);
|
|
3584
|
+
const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
|
|
3585
|
+
for (const width of [120, 60]) {
|
|
3586
|
+
const text = stripAnsi(panel.render(width).join("\n"));
|
|
3587
|
+
assert.match(flat(text), /⚠ subagents\.json routes "explor", "sdd-orchestratr-x", which no agent matches, so they are ignored\./);
|
|
3588
|
+
assert.doesNotMatch(text, /gentle-ai-worker/, "a key with a suggestion is not listed");
|
|
3589
|
+
assert.ok(!text.split("\n").some((line) => line.includes("…")), `nothing is cut at ${width}`);
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
// Issue #2400: when a pin wins, launches route from the pinned snapshot, so the
|
|
3594
|
+
// subagents.json keys are not the routing in effect and are not listed.
|
|
3595
|
+
test("the subagents.json notice stays off the screen while a pin wins", async (t) => {
|
|
3596
|
+
const { fixture, writeStore, writePin, localPinPath } = profilesStoreFixture(t);
|
|
3597
|
+
writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
|
|
3598
|
+
writePin(localPinPath, "team");
|
|
3599
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explor: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
3600
|
+
let text = "";
|
|
3601
|
+
fixture.onInput((visited) => {
|
|
3602
|
+
text = stripAnsi(renderComponent(visited));
|
|
3603
|
+
visited.handleInput("\x1b");
|
|
3604
|
+
});
|
|
3605
|
+
await fixture.run("gentle:profiles");
|
|
3606
|
+
assert.match(text, /Used in this repo\./);
|
|
3607
|
+
assert.doesNotMatch(text, /subagents\.json routes/);
|
|
3208
3608
|
});
|
|
3209
3609
|
|
|
3210
3610
|
// The name prompt behind c, d, and r: the Pi host starts the field empty and
|
|
3211
3611
|
// ignores the placeholder, and every outcome must show in the reopened panel,
|
|
3212
3612
|
// because the fullscreen overlay hides notifications until it closes.
|
|
3613
|
+
/** The keys that reach each profile action on the models screen. */
|
|
3614
|
+
const SCREEN_KEYS = {
|
|
3615
|
+
rename: ["m", "\r"],
|
|
3616
|
+
duplicate: ["m", "j", "\r"],
|
|
3617
|
+
useInRepo: ["m", "j", "j", "\r"],
|
|
3618
|
+
delete: ["m", "j", "j", "j", "\r"],
|
|
3619
|
+
newCopy: ["n", "\r"],
|
|
3620
|
+
newFromLive: ["n", "j", "\r"],
|
|
3621
|
+
newEmpty: ["n", "j", "j", "\r"],
|
|
3622
|
+
} as const;
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* Runs one screen action on a profile (the shown one by default) with scripted
|
|
3626
|
+
* name-prompt answers, then returns the screen the loop reopened with.
|
|
3627
|
+
*/
|
|
3213
3628
|
async function runProfilesNameAction(
|
|
3214
3629
|
fixture: ReturnType<typeof routingConsumerFixture>,
|
|
3215
|
-
keys:
|
|
3630
|
+
keys: readonly string[],
|
|
3631
|
+
...answers: Array<string | undefined>
|
|
3632
|
+
): Promise<string> {
|
|
3633
|
+
return runProfilesNameActionOn(fixture, undefined, keys, ...answers);
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
async function runProfilesNameActionOn(
|
|
3637
|
+
fixture: ReturnType<typeof routingConsumerFixture>,
|
|
3638
|
+
name: string | undefined,
|
|
3639
|
+
keys: readonly string[],
|
|
3216
3640
|
...answers: Array<string | undefined>
|
|
3217
3641
|
): Promise<string> {
|
|
3218
3642
|
fixture.answerInputs(...answers);
|
|
3219
|
-
let
|
|
3643
|
+
let acted = false;
|
|
3220
3644
|
fixture.onInput((panel) => {
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
for (
|
|
3645
|
+
if (acted) return panel.handleInput("\x1b");
|
|
3646
|
+
const tabs = tabNames(panel);
|
|
3647
|
+
for (let step = 0; name !== undefined && step < tabs.length && shownProfile(panel) !== name; step++) {
|
|
3648
|
+
panel.handleInput(tabs.indexOf(name) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C");
|
|
3649
|
+
}
|
|
3650
|
+
if (name !== undefined) assert.equal(shownProfile(panel), name);
|
|
3651
|
+
acted = true;
|
|
3652
|
+
for (const key of keys) panel.handleInput(key);
|
|
3224
3653
|
});
|
|
3225
3654
|
await fixture.run("gentle:profiles");
|
|
3226
|
-
|
|
3227
|
-
return fixture.panels[1];
|
|
3655
|
+
return fixture.panels.at(-1) ?? "";
|
|
3228
3656
|
}
|
|
3229
3657
|
|
|
3230
|
-
/** The
|
|
3658
|
+
/** The profile a rendered screen shows. */
|
|
3231
3659
|
function selectedProfileTitle(rendered: string): string {
|
|
3232
|
-
return (rendered.split("\n")[
|
|
3660
|
+
return (rendered.split("\n")[3] ?? "").replace(/^│\s*/, "").split(/\s{3}|\s*│/)[0]?.trim() ?? "";
|
|
3233
3661
|
}
|
|
3234
3662
|
|
|
3663
|
+
/** The feedback lines under the key hints of a rendered screen. */
|
|
3235
3664
|
function profilesPanelFooter(rendered: string): string {
|
|
3236
|
-
|
|
3665
|
+
const lines = rendered.split("\n");
|
|
3666
|
+
const hints = lines.findIndex((line) => line.includes("enter model"));
|
|
3667
|
+
return lines.slice(hints + 1, hints + 3).map((line) => line.replace(/^│\s*/, "").replace(/\s*│$/, "")).join(" ").trim();
|
|
3237
3668
|
}
|
|
3238
3669
|
|
|
3239
3670
|
test("c creates the named profile, selects it, and reports it in the reopened panel", async (t) => {
|
|
3240
3671
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3241
3672
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3242
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3673
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newEmpty, " deep-work ");
|
|
3243
3674
|
|
|
3244
3675
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3245
3676
|
assert.deepEqual(Object.keys(store.profiles), ["team", "deep-work"]);
|
|
@@ -3251,7 +3682,7 @@ test("c with an empty name creates nothing and says so in the reopened panel", a
|
|
|
3251
3682
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3252
3683
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3253
3684
|
const before = readFileSync(storePath, "utf8");
|
|
3254
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3685
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newEmpty, " ");
|
|
3255
3686
|
|
|
3256
3687
|
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3257
3688
|
assert.equal(selectedProfileTitle(reopened), "team");
|
|
@@ -3261,7 +3692,7 @@ test("c with an empty name creates nothing and says so in the reopened panel", a
|
|
|
3261
3692
|
test("d with an empty name duplicates to the suggested <name>-copy and selects it", async (t) => {
|
|
3262
3693
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3263
3694
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3264
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3695
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, "");
|
|
3265
3696
|
|
|
3266
3697
|
// The host never shows the placeholder, so the suggestion lives in the title.
|
|
3267
3698
|
assert.match(fixture.inputPrompts[0]?.title ?? "", /Duplicate profile "team" as \(empty = team-copy\)/);
|
|
@@ -3275,7 +3706,7 @@ test("d onto an existing name writes nothing and shows the conflict in the reope
|
|
|
3275
3706
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3276
3707
|
writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
|
|
3277
3708
|
const before = readFileSync(storePath, "utf8");
|
|
3278
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3709
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, "other");
|
|
3279
3710
|
|
|
3280
3711
|
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3281
3712
|
assert.equal(selectedProfileTitle(reopened), "team");
|
|
@@ -3286,7 +3717,7 @@ test("r with an empty name leaves the profile unchanged and says so in the reope
|
|
|
3286
3717
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3287
3718
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3288
3719
|
const before = readFileSync(storePath, "utf8");
|
|
3289
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3720
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.rename, "");
|
|
3290
3721
|
|
|
3291
3722
|
assert.match(fixture.inputPrompts[0]?.title ?? "", /Rename profile "team" to \(empty = keep team\)/);
|
|
3292
3723
|
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
@@ -3297,8 +3728,8 @@ test("r with an empty name leaves the profile unchanged and says so in the reope
|
|
|
3297
3728
|
test("r renames the profile, keeps it selected under the new name, and reports it", async (t) => {
|
|
3298
3729
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3299
3730
|
writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
|
|
3300
|
-
// Rename the second
|
|
3301
|
-
const reopened = await
|
|
3731
|
+
// Rename the second tab, so the reopened screen cannot land on it by default.
|
|
3732
|
+
const reopened = await runProfilesNameActionOn(fixture, "other", SCREEN_KEYS.rename, "focus");
|
|
3302
3733
|
|
|
3303
3734
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3304
3735
|
assert.deepEqual(Object.keys(store.profiles), ["team", "focus"]);
|
|
@@ -3311,16 +3742,17 @@ test("escape on the name prompt cancels without writing and says so in the reope
|
|
|
3311
3742
|
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3312
3743
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3313
3744
|
const before = readFileSync(storePath, "utf8");
|
|
3314
|
-
const reopened = await runProfilesNameAction(fixture,
|
|
3745
|
+
const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, undefined);
|
|
3315
3746
|
|
|
3316
3747
|
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3317
3748
|
assert.match(profilesPanelFooter(reopened), /Duplicate cancelled\./);
|
|
3318
3749
|
});
|
|
3319
3750
|
|
|
3320
|
-
// The pin is the per-repository layer of the profiles command:
|
|
3321
|
-
// clone-scoped pin inside the Git common directory,
|
|
3322
|
-
//
|
|
3323
|
-
|
|
3751
|
+
// The pin is the per-repository layer of the profiles command: Use in this repo
|
|
3752
|
+
// (profile menu) writes the clone-scoped pin inside the Git common directory,
|
|
3753
|
+
// `/gentle:profiles publish <name>` writes the committable per-worktree
|
|
3754
|
+
// declaration. Neither writes routing.
|
|
3755
|
+
test("Use in this repo pins the shown profile for the clone without touching the global routing", async (t) => {
|
|
3324
3756
|
const { fixture, storePath, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
3325
3757
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3326
3758
|
const storeBefore = readFileSync(storePath, "utf8");
|
|
@@ -3329,7 +3761,7 @@ test("p pins the selected profile for the clone without touching the global rout
|
|
|
3329
3761
|
fixture.onInput((panel) => {
|
|
3330
3762
|
if (firstPanel === undefined) {
|
|
3331
3763
|
firstPanel = panel;
|
|
3332
|
-
panel.handleInput(
|
|
3764
|
+
for (const key of SCREEN_KEYS.useInRepo) panel.handleInput(key);
|
|
3333
3765
|
} else {
|
|
3334
3766
|
reopenedPanel = panel;
|
|
3335
3767
|
panel.handleInput("\x1b");
|
|
@@ -3341,18 +3773,18 @@ test("p pins the selected profile for the clone without touching the global rout
|
|
|
3341
3773
|
readFileSync(localPinPath, "utf8"),
|
|
3342
3774
|
`${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
|
|
3343
3775
|
);
|
|
3344
|
-
assert.equal(existsSync(repoPinPath), false, "
|
|
3776
|
+
assert.equal(existsSync(repoPinPath), false, "Use in this repo writes the clone-scoped layer only");
|
|
3345
3777
|
assert.equal(readFileSync(storePath, "utf8"), storeBefore, "a pin stores a name, never routing");
|
|
3346
3778
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /pinned profile "team" for this clone in /);
|
|
3347
3779
|
assert.ok(firstPanel);
|
|
3348
|
-
// The
|
|
3349
|
-
// deliberately does not move.
|
|
3780
|
+
// The reopened screen marks the profile used in this repo (⌂) and says so.
|
|
3350
3781
|
assert.ok(reopenedPanel);
|
|
3351
|
-
assert.match(stripAnsi(renderComponent(reopenedPanel)), /
|
|
3352
|
-
|
|
3782
|
+
assert.match(stripAnsi(renderComponent(reopenedPanel)), /team ★⌂/);
|
|
3783
|
+
assert.match(stripAnsi(renderComponent(reopenedPanel)), /Used in this repo\./);
|
|
3784
|
+
// Rendering an existing screen must not resolve Git or read the pin again.
|
|
3353
3785
|
setProfilePinWorktreeResolverForTesting(() => { throw new Error("unexpected pin read during render"); });
|
|
3354
|
-
assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /
|
|
3355
|
-
assert.match(stripAnsi(renderComponent(reopenedPanel)), /
|
|
3786
|
+
assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /Used in this repo\./);
|
|
3787
|
+
assert.match(stripAnsi(renderComponent(reopenedPanel)), /Used in this repo\./);
|
|
3356
3788
|
});
|
|
3357
3789
|
|
|
3358
3790
|
test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
|
|
@@ -3370,26 +3802,17 @@ test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
|
|
|
3370
3802
|
assert.match(note, /profile-pin\.json/);
|
|
3371
3803
|
});
|
|
3372
3804
|
|
|
3373
|
-
test("
|
|
3805
|
+
test("publish declares the profile in the worktree so the routing can be committed", async (t) => {
|
|
3374
3806
|
const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
3375
3807
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3376
|
-
|
|
3377
|
-
fixture.
|
|
3378
|
-
if (firstPanel === undefined) {
|
|
3379
|
-
firstPanel = panel;
|
|
3380
|
-
panel.handleInput("P");
|
|
3381
|
-
panel.handleInput("\x1b");
|
|
3382
|
-
} else {
|
|
3383
|
-
panel.handleInput("\x1b");
|
|
3384
|
-
}
|
|
3385
|
-
});
|
|
3386
|
-
await fixture.run("gentle:profiles");
|
|
3808
|
+
await fixture.run("gentle:profiles", "publish team");
|
|
3809
|
+
assert.equal(fixture.panelVisits(), 0, "publish is a subcommand: no screen opens");
|
|
3387
3810
|
|
|
3388
3811
|
assert.equal(
|
|
3389
3812
|
readFileSync(repoPinPath, "utf8"),
|
|
3390
3813
|
`${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
|
|
3391
3814
|
);
|
|
3392
|
-
assert.equal(existsSync(localPinPath), false, "
|
|
3815
|
+
assert.equal(existsSync(localPinPath), false, "publish writes the worktree declaration only");
|
|
3393
3816
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /declared profile "team" for this worktree in /);
|
|
3394
3817
|
});
|
|
3395
3818
|
|
|
@@ -3403,7 +3826,7 @@ test("a stale pin is named as missing instead of silently changing nothing", asy
|
|
|
3403
3826
|
panel.handleInput("\x1b");
|
|
3404
3827
|
});
|
|
3405
3828
|
await fixture.run("gentle:profiles");
|
|
3406
|
-
assert.match(rendered ?? "",
|
|
3829
|
+
assert.match(rendered ?? "", /⚠ The local pin names "deleted-profile", which no profile defines, so it is\s+(│\s+)?ignored/);
|
|
3407
3830
|
});
|
|
3408
3831
|
|
|
3409
3832
|
test("pinning outside a Git worktree warns and writes nothing", async (t) => {
|
|
@@ -3415,15 +3838,17 @@ test("pinning outside a Git worktree warns and writes nothing", async (t) => {
|
|
|
3415
3838
|
fixture.onInput((panel) => {
|
|
3416
3839
|
assert.ok(++visits <= 2, "outside-worktree fixture repeatedly reopens and pins the panel");
|
|
3417
3840
|
// A completed panel ignores Escape; close the next visit instead.
|
|
3418
|
-
|
|
3841
|
+
if (visits === 1) for (const key of SCREEN_KEYS.useInRepo) panel.handleInput(key);
|
|
3842
|
+
else panel.handleInput("\x1b");
|
|
3419
3843
|
});
|
|
3420
3844
|
await fixture.run("gentle:profiles");
|
|
3421
3845
|
assert.equal(existsSync(localPinPath), false);
|
|
3422
3846
|
assert.equal(existsSync(repoPinPath), false);
|
|
3423
3847
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /not inside a Git worktree/);
|
|
3848
|
+
assert.match(profilesPanelFooter(fixture.panels.at(-1) ?? ""), /not inside a Git worktree, so nothing was pinned/);
|
|
3424
3849
|
});
|
|
3425
3850
|
|
|
3426
|
-
test("
|
|
3851
|
+
test("Stop using in this repo removes the clone pin", async (t) => {
|
|
3427
3852
|
const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
3428
3853
|
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3429
3854
|
writePin(localPinPath, "team");
|
|
@@ -3431,18 +3856,19 @@ test("pressing p again on the pinned profile removes the clone pin", async (t) =
|
|
|
3431
3856
|
fixture.onInput((panel) => {
|
|
3432
3857
|
if (firstPanel === undefined) {
|
|
3433
3858
|
firstPanel = panel;
|
|
3434
|
-
panel.handleInput("
|
|
3435
|
-
|
|
3859
|
+
panel.handleInput("m");
|
|
3860
|
+
assert.match(stripAnsi(renderComponent(panel)), /Stop using in this repo/, "the menu names the toggle by its effect");
|
|
3861
|
+
for (const key of SCREEN_KEYS.useInRepo.slice(1)) panel.handleInput(key);
|
|
3436
3862
|
} else {
|
|
3437
3863
|
panel.handleInput("\x1b");
|
|
3438
3864
|
}
|
|
3439
3865
|
});
|
|
3440
3866
|
await fixture.run("gentle:profiles");
|
|
3441
|
-
assert.equal(existsSync(localPinPath), false, "
|
|
3867
|
+
assert.equal(existsSync(localPinPath), false, "using it again removes the layer instead of rewriting it");
|
|
3442
3868
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /removed the local pin for this clone/);
|
|
3443
3869
|
});
|
|
3444
3870
|
|
|
3445
|
-
test("the profile
|
|
3871
|
+
test("the tab of the pinned profile carries ⌂ and the screen opens on it", async (t) => {
|
|
3446
3872
|
const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
3447
3873
|
writeStore({
|
|
3448
3874
|
team: { worker: { model: "openai/alpha" } },
|
|
@@ -3455,8 +3881,10 @@ test("the profile list marks the layer that wins with (pinned)", async (t) => {
|
|
|
3455
3881
|
panel.handleInput("\x1b");
|
|
3456
3882
|
});
|
|
3457
3883
|
await fixture.run("gentle:profiles");
|
|
3458
|
-
|
|
3459
|
-
assert.
|
|
3884
|
+
const tabLine = (rendered ?? "").split("\n")[1] ?? "";
|
|
3885
|
+
assert.match(tabLine, /other ⌂/);
|
|
3886
|
+
assert.doesNotMatch(tabLine, /team ★⌂/);
|
|
3887
|
+
assert.equal(selectedProfileTitle(rendered ?? ""), "other", "a winning pin is the profile the screen opens on");
|
|
3460
3888
|
});
|
|
3461
3889
|
|
|
3462
3890
|
test("an invalid pin file is surfaced by the panel instead of reading as no pin", async (t) => {
|
|
@@ -3470,8 +3898,9 @@ test("an invalid pin file is surfaced by the panel instead of reading as no pin"
|
|
|
3470
3898
|
panel.handleInput("\x1b");
|
|
3471
3899
|
});
|
|
3472
3900
|
await fixture.run("gentle:profiles");
|
|
3473
|
-
|
|
3474
|
-
assert.
|
|
3901
|
+
// The notice wraps between words; the frame's border columns sit between the lines.
|
|
3902
|
+
assert.match((rendered ?? "").replace(/\s*│\s*/g, " ").replace(/\s+/g, " "), /⚠ The local pin file .* is not a valid pin and is ignored\./);
|
|
3903
|
+
assert.doesNotMatch(rendered ?? "", /which no profile defines/);
|
|
3475
3904
|
assert.equal(readFileSync(localPinPath, "utf8"), "{ not json\n", "reading never rewrites a broken pin");
|
|
3476
3905
|
});
|
|
3477
3906
|
|
|
@@ -3515,16 +3944,8 @@ test("deleting a profile is refused when a non-winning pin layer names it", asyn
|
|
|
3515
3944
|
// does not win but still names a live profile for this repository.
|
|
3516
3945
|
writePin(localPinPath, "other");
|
|
3517
3946
|
writePin(repoPinPath, "team");
|
|
3518
|
-
|
|
3519
|
-
fixture.
|
|
3520
|
-
if (firstPanel === undefined) {
|
|
3521
|
-
firstPanel = panel;
|
|
3522
|
-
panel.handleInput("x");
|
|
3523
|
-
panel.handleInput("\x1b");
|
|
3524
|
-
} else {
|
|
3525
|
-
panel.handleInput("\x1b");
|
|
3526
|
-
}
|
|
3527
|
-
});
|
|
3947
|
+
// The screen opens on "other" (the winning pin); go to "team" and delete it.
|
|
3948
|
+
actOnProfile(fixture, SCREEN_KEYS.delete, "team");
|
|
3528
3949
|
await fixture.run("gentle:profiles");
|
|
3529
3950
|
assert.equal(existsSync(repoPinPath), true, "the declaration survives the refused delete");
|
|
3530
3951
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /is pinned for this repository/);
|
|
@@ -3730,7 +4151,7 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin
|
|
|
3730
4151
|
getThinkingLevel(): ThinkingLevel { return "medium"; },
|
|
3731
4152
|
};
|
|
3732
4153
|
const file = readValidProfilesStore(storePath);
|
|
3733
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "
|
|
4154
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
|
|
3734
4155
|
assert.deepEqual(models, [{ provider: "openai", id: "beta" }]);
|
|
3735
4156
|
assert.deepEqual(thinking, ["high"]);
|
|
3736
4157
|
const binding = readSessionProfileBinding("session-panel");
|
|
@@ -3742,7 +4163,7 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin
|
|
|
3742
4163
|
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written");
|
|
3743
4164
|
const applied = notifications.at(-1)?.message ?? "";
|
|
3744
4165
|
assert.match(applied, /bound profile "team" to this session/);
|
|
3745
|
-
assert.match(applied, /
|
|
4166
|
+
assert.match(applied, /its tab shows ◉/);
|
|
3746
4167
|
assert.match(applied, /This session now runs on openai\/beta · high/);
|
|
3747
4168
|
assert.match(applied, /Subagents and reviewers use this session's routing snapshot/);
|
|
3748
4169
|
assert.match(applied, /global routing, pins, and materialized stores are untouched/);
|
|
@@ -3772,7 +4193,7 @@ test("Enter switches only the selecting session's orchestrator and leaves shared
|
|
|
3772
4193
|
setThinkingLevel: (level: ThinkingLevel) => { thinking.push(level); },
|
|
3773
4194
|
getThinkingLevel: (): ThinkingLevel => "medium",
|
|
3774
4195
|
} as unknown as LiveSession;
|
|
3775
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "
|
|
4196
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" });
|
|
3776
4197
|
assert.deepEqual(switches, ["openai/alpha"]);
|
|
3777
4198
|
assert.deepEqual(thinking, ["high"]);
|
|
3778
4199
|
assert.equal(readSessionProfileBinding("selecting-session")?.name, "team");
|
|
@@ -3826,7 +4247,7 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca
|
|
|
3826
4247
|
setThinkingLevel() { thinkingCalls++; },
|
|
3827
4248
|
getThinkingLevel(): ThinkingLevel { return "medium"; },
|
|
3828
4249
|
};
|
|
3829
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "
|
|
4250
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }, {});
|
|
3830
4251
|
assert.equal(modelCalls, rejected ? 1 : 0);
|
|
3831
4252
|
assert.equal(thinkingCalls, 0);
|
|
3832
4253
|
assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha");
|
|
@@ -3850,7 +4271,7 @@ test("a keeps the legacy global apply semantics", async (t) => {
|
|
|
3850
4271
|
} as unknown as ExtensionContext;
|
|
3851
4272
|
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3852
4273
|
const file = readValidProfilesStore(storePath);
|
|
3853
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {});
|
|
4274
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team", config: file.profiles["team"] }, {});
|
|
3854
4275
|
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile");
|
|
3855
4276
|
assert.ok(existsSync(fixture.globalPath), "the global routing is materialized");
|
|
3856
4277
|
resetSessionProfileBindingsForTesting();
|
|
@@ -3871,7 +4292,7 @@ test("Enter with a winning pin binds the session and never touches the pin layer
|
|
|
3871
4292
|
} as unknown as ExtensionContext;
|
|
3872
4293
|
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3873
4294
|
const file = readValidProfilesStore(storePath);
|
|
3874
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "
|
|
4295
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
|
|
3875
4296
|
assert.equal(readSessionProfileBinding("session-panel")?.name, "team");
|
|
3876
4297
|
assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched");
|
|
3877
4298
|
assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched");
|
|
@@ -3879,69 +4300,22 @@ test("Enter with a winning pin binds the session and never touches the pin layer
|
|
|
3879
4300
|
resetSessionProfileBindingsForTesting();
|
|
3880
4301
|
});
|
|
3881
4302
|
|
|
3882
|
-
test("a session-bound
|
|
3883
|
-
const { fixture,
|
|
3884
|
-
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3885
|
-
mkdirSync(fixture.configHome, { recursive: true });
|
|
3886
|
-
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
3887
|
-
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3888
|
-
bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
|
|
3889
|
-
fixture.onInput((panel) => {
|
|
3890
|
-
const rendered = renderComponent(panel);
|
|
3891
|
-
assert.match(rendered, /Current routing \(effective\)/);
|
|
3892
|
-
assert.match(rendered, /openai\/gamma/, "the current routing is the session binding's snapshot");
|
|
3893
|
-
assert.doesNotMatch(rendered, /openai\/alpha/, "the global routing stays out of a bound session's panel");
|
|
3894
|
-
assert.match(
|
|
3895
|
-
rendered,
|
|
3896
|
-
/team \(session\) — launches resolve it ahead of pins/,
|
|
3897
|
-
"the session line states what the binding governs post-#1558 (the panel truncates long lines)",
|
|
3898
|
-
);
|
|
3899
|
-
assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives in the panel");
|
|
3900
|
-
panel.handleInput("\x1b");
|
|
3901
|
-
});
|
|
3902
|
-
await fixture.run("gentle:profiles");
|
|
3903
|
-
resetSessionProfileBindingsForTesting();
|
|
3904
|
-
});
|
|
3905
|
-
|
|
3906
|
-
test("the now line reports the live session's orchestrator over the settings defaults", async (t) => {
|
|
3907
|
-
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3908
|
-
writeSettings();
|
|
3909
|
-
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3910
|
-
// The session runs live on openai/omega at low effort while settings.json still
|
|
3911
|
-
// defaults to nan/deepseek-v4-flash at high: the panel's now line must report
|
|
3912
|
-
// what this session actually runs, not the default new sessions would get.
|
|
3913
|
-
fixture.setLiveModel("openai", "omega", "low");
|
|
3914
|
-
fixture.onInput((panel) => {
|
|
3915
|
-
const rendered = renderComponent(panel);
|
|
3916
|
-
assert.match(rendered, /now\s+openai\/omega/, "the now line shows the live session's orchestrator");
|
|
3917
|
-
assert.doesNotMatch(rendered, /now\s+nan\//, "the settings default stays off the now line when a live model runs");
|
|
3918
|
-
panel.handleInput("\x1b");
|
|
3919
|
-
});
|
|
3920
|
-
await fixture.run("gentle:profiles");
|
|
3921
|
-
});
|
|
3922
|
-
|
|
3923
|
-
test("the (session) marker survives a snapshot refresh of the panel list", async (t) => {
|
|
3924
|
-
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3925
|
-
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3926
|
-
mkdirSync(fixture.configHome, { recursive: true });
|
|
3927
|
-
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
4303
|
+
test("a session-bound profile carries ◉ and says it is used in this session", async (t) => {
|
|
4304
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
4305
|
+
writeStore({ team: { worker: { model: "openai/beta" } }, other: {} }, "team");
|
|
3928
4306
|
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3929
4307
|
bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
|
|
4308
|
+
let rendered = "";
|
|
3930
4309
|
fixture.onInput((panel) => {
|
|
3931
|
-
|
|
3932
|
-
panel.handleInput("s");
|
|
3933
|
-
assert.match(renderComponent(panel), /Snapshot saved; live routing unchanged\./);
|
|
3934
|
-
assert.match(renderComponent(panel), /team \(active\) \(session\)/, "the refreshed list keeps the session marker");
|
|
4310
|
+
rendered = stripAnsi(renderComponent(panel));
|
|
3935
4311
|
panel.handleInput("\x1b");
|
|
3936
4312
|
});
|
|
3937
4313
|
await fixture.run("gentle:profiles");
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
assert.
|
|
3943
|
-
assert.notEqual(saved.profiles.team?.worker?.model, "openai/alpha", "the global routing never leaks into the saved snapshot");
|
|
3944
|
-
resetSessionProfileBindingsForTesting();
|
|
4314
|
+
const tabLine = rendered.split("\n")[1] ?? "";
|
|
4315
|
+
assert.match(tabLine, /team ◉★/);
|
|
4316
|
+
assert.doesNotMatch(tabLine, /other ◉/);
|
|
4317
|
+
assert.match(rendered, /Used in this session\. Default for new sessions\./);
|
|
4318
|
+
assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives on the screen");
|
|
3945
4319
|
});
|
|
3946
4320
|
|
|
3947
4321
|
test("Enter without a parent session id fails loud and writes nothing", async (t) => {
|
|
@@ -3956,10 +4330,116 @@ test("Enter without a parent session id fails loud and writes nothing", async (t
|
|
|
3956
4330
|
} as unknown as ExtensionContext;
|
|
3957
4331
|
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3958
4332
|
const file = readValidProfilesStore(storePath);
|
|
3959
|
-
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "
|
|
4333
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
|
|
3960
4334
|
assert.equal(readSessionProfileBinding(undefined), undefined);
|
|
3961
4335
|
assert.equal(readFileSync(storePath, "utf8"), before);
|
|
3962
4336
|
assert.equal(existsSync(fixture.globalPath), false);
|
|
3963
4337
|
assert.equal(notifications.at(-1)?.severity, "warning");
|
|
3964
4338
|
resetSessionProfileBindingsForTesting();
|
|
3965
4339
|
});
|
|
4340
|
+
|
|
4341
|
+
test("file operations are subcommands: profiles restore-routing, export and import, and usage for anything else", async (t) => {
|
|
4342
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
4343
|
+
writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
|
|
4344
|
+
|
|
4345
|
+
// /gentle:profiles restore-routing: confirm, then write models.json and materialize it.
|
|
4346
|
+
writeMarkdown(fixture.exportPath, `${JSON.stringify({ kind: "gentle-pi.agent_model_routing", version: 1, agents: { worker: { model: "openai/beta", thinking: "low" } } }, null, 2)}\n`);
|
|
4347
|
+
fixture.onConfirm(async () => false);
|
|
4348
|
+
await fixture.run("gentle:profiles", "restore-routing");
|
|
4349
|
+
assert.equal(existsSync(fixture.globalPath), false, "a declined restore writes nothing");
|
|
4350
|
+
fixture.onConfirm(async () => true);
|
|
4351
|
+
await fixture.run("gentle:profiles", "restore-routing");
|
|
4352
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")).worker, { model: "openai/beta", thinking: "low" });
|
|
4353
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /restored global model config/);
|
|
4354
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/beta/);
|
|
4355
|
+
|
|
4356
|
+
// /gentle:profiles export-routing writes the saved routing, not a profile.
|
|
4357
|
+
await fixture.run("gentle:profiles", "export-routing");
|
|
4358
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, { worker: { model: "openai/beta", thinking: "low" } });
|
|
4359
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /exported 1 saved model routing entry/);
|
|
4360
|
+
|
|
4361
|
+
// /gentle:profiles export <name> then import it back over a changed store.
|
|
4362
|
+
await fixture.run("gentle:profiles", "export team");
|
|
4363
|
+
const exportFile = join(fixture.configHome, "profiles.export.json");
|
|
4364
|
+
assert.ok(existsSync(exportFile));
|
|
4365
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /exported profile "team"/);
|
|
4366
|
+
writeStore({ team: { worker: { model: "openai/gamma" } } }, "team");
|
|
4367
|
+
await fixture.run("gentle:profiles", "import");
|
|
4368
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha", thinking: "high" } }, "import replaces after confirmation");
|
|
4369
|
+
|
|
4370
|
+
for (const args of ["export", "export ghost", "publish", "frobnicate", "restore", "export-routing extra"]) {
|
|
4371
|
+
await fixture.run("gentle:profiles", args);
|
|
4372
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /Usage: \/gentle:profiles \[export <name> \| import \| publish <name> \| export-routing \| restore-routing\]/, args);
|
|
4373
|
+
}
|
|
4374
|
+
assert.equal(fixture.panelVisits(), 0, "no subcommand opens the screen");
|
|
4375
|
+
});
|
|
4376
|
+
|
|
4377
|
+
test("gentle:models is gone: gentle:profiles is the only models and profiles command", () => {
|
|
4378
|
+
const commands = new Map<string, { description?: string }>();
|
|
4379
|
+
createGentleAiExtension({ nativeReviewCli: null })({
|
|
4380
|
+
on() {},
|
|
4381
|
+
registerTool() {},
|
|
4382
|
+
registerCommand(name: string, command: { description?: string }) { commands.set(name, command); },
|
|
4383
|
+
} as unknown as ExtensionAPI);
|
|
4384
|
+
assert.equal(commands.has("gentle:models"), false);
|
|
4385
|
+
assert.match(commands.get("gentle:profiles")?.description ?? "", /export-routing, restore-routing/);
|
|
4386
|
+
for (const [name, command] of commands) {
|
|
4387
|
+
assert.doesNotMatch(command.description ?? "", /gentle:models/, name);
|
|
4388
|
+
}
|
|
4389
|
+
});
|
|
4390
|
+
|
|
4391
|
+
test("gentle:profiles still opens the screen and keeps export <name>, import, and publish <name>", async (t) => {
|
|
4392
|
+
const { fixture, storePath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
4393
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
4394
|
+
fixture.onInput((panel) => {
|
|
4395
|
+
assert.equal(shownProfile(panel), "team");
|
|
4396
|
+
panel.handleInput("\x1b");
|
|
4397
|
+
});
|
|
4398
|
+
await fixture.run("gentle:profiles");
|
|
4399
|
+
assert.equal(fixture.panelVisits(), 1);
|
|
4400
|
+
await fixture.run("gentle:profiles", "export team");
|
|
4401
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /exported profile "team"/);
|
|
4402
|
+
writeStore({ team: { worker: { model: "openai/gamma" } } }, "team");
|
|
4403
|
+
await fixture.run("gentle:profiles", "import");
|
|
4404
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha" } });
|
|
4405
|
+
await fixture.run("gentle:profiles", "publish team");
|
|
4406
|
+
assert.equal(JSON.parse(readFileSync(repoPinPath, "utf8")).profile, "team");
|
|
4407
|
+
assert.equal(fixture.panelVisits(), 1, "subcommands open no screen");
|
|
4408
|
+
});
|
|
4409
|
+
|
|
4410
|
+
test("switching profile tabs redraws in place: the overlay never closes and reopens", async (t) => {
|
|
4411
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
4412
|
+
writeStore({
|
|
4413
|
+
team: { worker: { model: "openai/alpha" } },
|
|
4414
|
+
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
4415
|
+
}, "team");
|
|
4416
|
+
const frames: string[] = [];
|
|
4417
|
+
let visits = 0;
|
|
4418
|
+
fixture.onInput((panel) => {
|
|
4419
|
+
visits += 1;
|
|
4420
|
+
if (visits > 1) {
|
|
4421
|
+
panel.handleInput("\x1b");
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
frames.push(stripAnsi(renderComponent(panel)));
|
|
4425
|
+
panel.handleInput(RIGHT_KEY); // clean switch to "other"
|
|
4426
|
+
frames.push(stripAnsi(renderComponent(panel)));
|
|
4427
|
+
chooseAgent(panel, "worker");
|
|
4428
|
+
chooseModel(panel, "alpha");
|
|
4429
|
+
panel.handleInput("\x1b[D"); // back to "team" with an unsaved edit: asks first
|
|
4430
|
+
frames.push(stripAnsi(renderComponent(panel)));
|
|
4431
|
+
panel.handleInput("s"); // Save and switch
|
|
4432
|
+
frames.push(stripAnsi(renderComponent(panel)));
|
|
4433
|
+
panel.handleInput("\x1b");
|
|
4434
|
+
});
|
|
4435
|
+
await fixture.run("gentle:profiles");
|
|
4436
|
+
assert.equal(fixture.panelVisits(), 1, "a tab switch keeps the one overlay on screen");
|
|
4437
|
+
const [first, switched, asking, saved] = frames;
|
|
4438
|
+
assert.match(first ?? "", /worker\s+openai\/alpha/);
|
|
4439
|
+
assert.match(switched ?? "", /worker\s+openai\/beta\s+high/, "the switched frame is already the new profile");
|
|
4440
|
+
assert.match(profilesPanelFooter(switched ?? ""), /Showing "other"\./);
|
|
4441
|
+
assert.match(asking ?? "", /other has 1 unsaved change\./);
|
|
4442
|
+
assert.match(saved ?? "", /worker\s+openai\/alpha/);
|
|
4443
|
+
assert.match(profilesPanelFooter(saved ?? ""), /Saved "other"\. Showing "team"\./);
|
|
4444
|
+
assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.other, { worker: { model: "openai/alpha", thinking: "high" } });
|
|
4445
|
+
});
|