gentle-pi 2.7.0 → 3.0.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 +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
17
17
|
import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
|
|
18
|
+
import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
|
|
18
19
|
import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
19
20
|
import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
20
21
|
import { installPackageAssets } from "../lib/sdd-preflight.ts";
|
|
@@ -1783,9 +1784,9 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
|
|
|
1783
1784
|
assert.match(preview, /push origin main && npm publish --tag beta/);
|
|
1784
1785
|
assert.ok(preview.startsWith("…"));
|
|
1785
1786
|
});
|
|
1786
|
-
// /gentle:profiles reopens its panel after
|
|
1787
|
-
// once must confirm on the first visit and close on the
|
|
1788
|
-
// the action loop feed each other forever.
|
|
1787
|
+
// /gentle:profiles reopens its panel after actions that finish the interaction,
|
|
1788
|
+
// so a test that applies once must confirm on the first visit and close on the
|
|
1789
|
+
// next, or the panel and the action loop feed each other forever.
|
|
1789
1790
|
function applyOnce(
|
|
1790
1791
|
fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
|
|
1791
1792
|
): void {
|
|
@@ -1800,6 +1801,19 @@ function profilesStoreFixture(t: test.TestContext) {
|
|
|
1800
1801
|
const fixture = routingConsumerFixture(t, ["worker"]);
|
|
1801
1802
|
const storePath = join(fixture.configHome, "profiles.json");
|
|
1802
1803
|
const settingsPath = join(fixture.agentHome, "settings.json");
|
|
1804
|
+
// The panel resolves the per-repository profile pin through a Git seam, so
|
|
1805
|
+
// point it at a sandbox identity: a pin test can then write the two layers the
|
|
1806
|
+
// production resolver reads without depending on where the test runner's
|
|
1807
|
+
// directory happens to sit.
|
|
1808
|
+
const pinRoot = join(fixture.root, "worktree");
|
|
1809
|
+
const pinCommonDir = join(fixture.root, "git-common");
|
|
1810
|
+
for (const dir of [pinRoot, pinCommonDir]) mkdirSync(dir, { recursive: true });
|
|
1811
|
+
setProfilePinWorktreeResolverForTesting(() => ({ root: pinRoot, commonDir: pinCommonDir }));
|
|
1812
|
+
t.after(() => setProfilePinWorktreeResolverForTesting());
|
|
1813
|
+
const writePin = (path: string, profile: string) => {
|
|
1814
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1815
|
+
writeFileSync(path, `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile }, null, 2)}\n`);
|
|
1816
|
+
};
|
|
1803
1817
|
const writeStore = (profiles: Record<string, unknown>, active?: string) => {
|
|
1804
1818
|
mkdirSync(fixture.configHome, { recursive: true });
|
|
1805
1819
|
const store: Record<string, unknown> = { kind: PROFILES_KIND, version: PROFILES_VERSION, profiles };
|
|
@@ -1818,7 +1832,16 @@ function profilesStoreFixture(t: test.TestContext) {
|
|
|
1818
1832
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1819
1833
|
return settings;
|
|
1820
1834
|
};
|
|
1821
|
-
return {
|
|
1835
|
+
return {
|
|
1836
|
+
fixture,
|
|
1837
|
+
storePath,
|
|
1838
|
+
settingsPath,
|
|
1839
|
+
writeStore,
|
|
1840
|
+
writeSettings,
|
|
1841
|
+
localPinPath: join(pinCommonDir, "gentle-ai", "profile-pin.json"),
|
|
1842
|
+
repoPinPath: join(pinRoot, ".pi", "gentle-ai", "profile.json"),
|
|
1843
|
+
writePin,
|
|
1844
|
+
};
|
|
1822
1845
|
}
|
|
1823
1846
|
|
|
1824
1847
|
test("applying a profile persists its orchestrator and never leaks the key into agent routing", async (t) => {
|
|
@@ -1930,6 +1953,198 @@ test("a failed apply restores the previous profile's routing with the same repla
|
|
|
1930
1953
|
assert.equal(store.active, "old");
|
|
1931
1954
|
});
|
|
1932
1955
|
|
|
1956
|
+
test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
|
|
1957
|
+
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
1958
|
+
writeSettings();
|
|
1959
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1960
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "minimal" } }, null, 2)}\n`);
|
|
1961
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
1962
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
1963
|
+
const workerPath = join(fixture.root, ".pi", "agents", "worker.md");
|
|
1964
|
+
const before = {
|
|
1965
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
1966
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
1967
|
+
worker: readFileSync(workerPath, "utf8"),
|
|
1968
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
1969
|
+
};
|
|
1970
|
+
writeStore({
|
|
1971
|
+
"a-target": {},
|
|
1972
|
+
"z-active": { worker: { model: "openai/beta" } },
|
|
1973
|
+
}, "z-active");
|
|
1974
|
+
|
|
1975
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
1976
|
+
fixture.onInput((panel) => {
|
|
1977
|
+
if (firstPanel === undefined) {
|
|
1978
|
+
firstPanel = panel;
|
|
1979
|
+
panel.handleInput("s");
|
|
1980
|
+
panel.handleInput("\x1b");
|
|
1981
|
+
} else {
|
|
1982
|
+
panel.handleInput("\x1b");
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
await fixture.run("gentle:profiles");
|
|
1986
|
+
|
|
1987
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
1988
|
+
assert.deepEqual(store.profiles["a-target"], {
|
|
1989
|
+
worker: { model: "openai/alpha", thinking: "minimal" },
|
|
1990
|
+
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
1991
|
+
});
|
|
1992
|
+
assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
|
|
1993
|
+
assert.equal(store.active, "z-active");
|
|
1994
|
+
assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/);
|
|
1995
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
|
|
1996
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
|
|
1997
|
+
assert.equal(readFileSync(workerPath, "utf8"), before.worker);
|
|
1998
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
|
|
1999
|
+
assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
|
|
2000
|
+
assert.ok(firstPanel);
|
|
2001
|
+
const targetRow = renderComponent(firstPanel!).split("\n");
|
|
2002
|
+
const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
|
|
2003
|
+
assert.ok(targetIndex >= 0, "selected profile remains in the list");
|
|
2004
|
+
assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
|
|
2005
|
+
assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
// /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
|
|
2009
|
+
// by the snapshot `/gentle:profiles` performs with `s` on the current profile.
|
|
2010
|
+
function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
|
|
2011
|
+
// Rows: "Set all agents", the two provider review roles, then "worker".
|
|
2012
|
+
for (let step = 0; step < 3; step += 1) panel.handleInput("j");
|
|
2013
|
+
assert.match(renderComponent(panel), /▸ worker/);
|
|
2014
|
+
panel.handleInput("\r");
|
|
2015
|
+
for (const character of "alpha") panel.handleInput(character);
|
|
2016
|
+
panel.handleInput("\r");
|
|
2017
|
+
panel.handleInput("u");
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
test("u saves global routing from /gentle:models and updates the active profile", async (t) => {
|
|
2021
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2022
|
+
writeSettings();
|
|
2023
|
+
writeStore({
|
|
2024
|
+
team: { worker: { model: "openai/beta" } },
|
|
2025
|
+
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
2026
|
+
}, "team");
|
|
2027
|
+
fixture.onInput((panel) => {
|
|
2028
|
+
assert.match(renderComponent(panel), /Current profile: team/);
|
|
2029
|
+
assert.match(renderComponent(panel), /u update profile/);
|
|
2030
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2031
|
+
});
|
|
2032
|
+
await fixture.run("gentle:models");
|
|
2033
|
+
|
|
2034
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2035
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2036
|
+
assert.deepEqual(store.profiles.team, {
|
|
2037
|
+
worker: { model: "openai/alpha" },
|
|
2038
|
+
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2039
|
+
});
|
|
2040
|
+
assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
|
|
2041
|
+
assert.equal(store.active, "team");
|
|
2042
|
+
assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
|
|
2043
|
+
assert.ok(
|
|
2044
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
|
|
2045
|
+
"the global save is still reported",
|
|
2046
|
+
);
|
|
2047
|
+
assert.ok(
|
|
2048
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
|
|
2049
|
+
`profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2050
|
+
);
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
|
|
2054
|
+
const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
|
|
2055
|
+
writeSettings();
|
|
2056
|
+
writeStore({
|
|
2057
|
+
team: { worker: { model: "openai/beta" } },
|
|
2058
|
+
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
2059
|
+
}, "team");
|
|
2060
|
+
writePin(localPinPath, "other");
|
|
2061
|
+
fixture.onInput((panel) => {
|
|
2062
|
+
assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
|
|
2063
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2064
|
+
});
|
|
2065
|
+
await fixture.run("gentle:models");
|
|
2066
|
+
|
|
2067
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2068
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2069
|
+
assert.deepEqual(store.profiles.other, {
|
|
2070
|
+
worker: { model: "openai/alpha" },
|
|
2071
|
+
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2072
|
+
});
|
|
2073
|
+
assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
|
|
2074
|
+
assert.equal(store.active, "team");
|
|
2075
|
+
assert.ok(
|
|
2076
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
|
|
2077
|
+
`pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2078
|
+
);
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
test("u keeps the global save and reports when no profile is current", async (t) => {
|
|
2082
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2083
|
+
writeSettings();
|
|
2084
|
+
writeStore({ team: { worker: { model: "openai/beta" } } });
|
|
2085
|
+
const before = readFileSync(storePath, "utf8");
|
|
2086
|
+
fixture.onInput((panel) => {
|
|
2087
|
+
assert.match(renderComponent(panel), /Current profile: none/);
|
|
2088
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2089
|
+
});
|
|
2090
|
+
await fixture.run("gentle:models");
|
|
2091
|
+
|
|
2092
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2093
|
+
assert.equal(readFileSync(storePath, "utf8"), before, "the store is untouched");
|
|
2094
|
+
assert.ok(
|
|
2095
|
+
fixture.notifications.some((entry) => entry.severity === "warning" && /no profile is current/.test(entry.message) && /\/gentle:profiles/.test(entry.message)),
|
|
2096
|
+
`missing current profile is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2097
|
+
);
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
test("u seeds the profiles store the way /gentle:profiles does when it is missing", async (t) => {
|
|
2101
|
+
const { fixture, storePath, writeSettings } = profilesStoreFixture(t);
|
|
2102
|
+
writeSettings();
|
|
2103
|
+
assert.equal(existsSync(storePath), false);
|
|
2104
|
+
fixture.onInput((panel) => {
|
|
2105
|
+
assert.match(renderComponent(panel), /Current profile: none/);
|
|
2106
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2107
|
+
});
|
|
2108
|
+
await fixture.run("gentle:models");
|
|
2109
|
+
|
|
2110
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2111
|
+
assert.equal(store.kind, PROFILES_KIND);
|
|
2112
|
+
assert.deepEqual(store.profiles.current, {
|
|
2113
|
+
worker: { model: "openai/alpha" },
|
|
2114
|
+
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2115
|
+
});
|
|
2116
|
+
assert.equal(store.active, "current");
|
|
2117
|
+
assert.ok(
|
|
2118
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /seeded the "current" profile/.test(entry.message)),
|
|
2119
|
+
`seeding is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2120
|
+
);
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
test("snapshot feedback keeps both outcomes visible for long profile names at narrow widths", async (t) => {
|
|
2124
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
2125
|
+
const longName = `a${"x".repeat(63)}`;
|
|
2126
|
+
writeStore({ [longName]: {} });
|
|
2127
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2128
|
+
fixture.onInput((panel) => {
|
|
2129
|
+
if (firstPanel === undefined) {
|
|
2130
|
+
firstPanel = panel;
|
|
2131
|
+
panel.handleInput("s");
|
|
2132
|
+
const success = stripAnsi(panel.render(60).join("\n"));
|
|
2133
|
+
assert.match(success, /Snapshot saved; live routing unchanged\./);
|
|
2134
|
+
rmSync(storePath);
|
|
2135
|
+
mkdirSync(storePath);
|
|
2136
|
+
panel.handleInput("s");
|
|
2137
|
+
panel.handleInput("\x1b");
|
|
2138
|
+
} else {
|
|
2139
|
+
panel.handleInput("\x1b");
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
await fixture.run("gentle:profiles");
|
|
2143
|
+
assert.ok(firstPanel);
|
|
2144
|
+
const constrained = stripAnsi(firstPanel!.render(60).join("\n"));
|
|
2145
|
+
assert.match(constrained, /Snapshot failed; live routing unchanged\./);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
1933
2148
|
test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
|
|
1934
2149
|
const { fixture } = profilesStoreFixture(t);
|
|
1935
2150
|
// No models.json at all, but routing is materialized where the runtime
|
|
@@ -1956,6 +2171,22 @@ test("the profiles command seeds and shows the routing the runtime uses when mod
|
|
|
1956
2171
|
assert.doesNotMatch(rendered, /No routing entries/);
|
|
1957
2172
|
});
|
|
1958
2173
|
|
|
2174
|
+
for (const reader of ["readEffectiveModelConfig", "readEffectiveModelConfigAsync"] as const) {
|
|
2175
|
+
test(`${reader} retains frontmatter model with a profile-only effort override`, async (t) => {
|
|
2176
|
+
const fixture = routingConsumerFixture(t, ["worker"]);
|
|
2177
|
+
writeMarkdown(join(fixture.root, ".pi", "agents", "worker.md"), "---\nname: worker\ndescription: Worker\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\n---\nbody\n");
|
|
2178
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
|
|
2179
|
+
model_profiles: { worker: { effort: "low" } },
|
|
2180
|
+
})}\n`);
|
|
2181
|
+
|
|
2182
|
+
const effective = await __testing[reader](fixture.root);
|
|
2183
|
+
assert.deepEqual(JSON.parse(JSON.stringify(effective.worker)), {
|
|
2184
|
+
model: "openai-codex/gpt-5.6-terra",
|
|
2185
|
+
thinking: "low",
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
1959
2190
|
test("effective routing prefers models.json over the materialized stores", (t) => {
|
|
1960
2191
|
const fixture = routingConsumerFixture(t, ["worker", "helper"]);
|
|
1961
2192
|
mkdirSync(fixture.configHome, { recursive: true });
|
|
@@ -2043,3 +2274,240 @@ test("j and k scroll the detail pane one line at a time, like the agents view",
|
|
|
2043
2274
|
panel!.handleInput("k");
|
|
2044
2275
|
assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
|
|
2045
2276
|
});
|
|
2277
|
+
|
|
2278
|
+
// The pin is the per-repository layer of the profiles command: `p` writes the
|
|
2279
|
+
// clone-scoped pin inside the Git common directory, `P` writes the committable
|
|
2280
|
+
// per-worktree declaration. Neither writes routing.
|
|
2281
|
+
test("p pins the selected profile for the clone without touching the global routing", async (t) => {
|
|
2282
|
+
const { fixture, storePath, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
2283
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2284
|
+
const storeBefore = readFileSync(storePath, "utf8");
|
|
2285
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2286
|
+
let reopenedPanel: RoutingConsumerPanel | undefined;
|
|
2287
|
+
fixture.onInput((panel) => {
|
|
2288
|
+
if (firstPanel === undefined) {
|
|
2289
|
+
firstPanel = panel;
|
|
2290
|
+
panel.handleInput("p");
|
|
2291
|
+
} else {
|
|
2292
|
+
reopenedPanel = panel;
|
|
2293
|
+
panel.handleInput("\x1b");
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2296
|
+
await fixture.run("gentle:profiles");
|
|
2297
|
+
|
|
2298
|
+
assert.equal(
|
|
2299
|
+
readFileSync(localPinPath, "utf8"),
|
|
2300
|
+
`${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
|
|
2301
|
+
);
|
|
2302
|
+
assert.equal(existsSync(repoPinPath), false, "p writes the clone-scoped layer only");
|
|
2303
|
+
assert.equal(readFileSync(storePath, "utf8"), storeBefore, "a pin stores a name, never routing");
|
|
2304
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /pinned profile "team" for this clone in /);
|
|
2305
|
+
assert.ok(firstPanel);
|
|
2306
|
+
// The panel reports the layer that would win, next to the orchestrator lines it
|
|
2307
|
+
// deliberately does not move.
|
|
2308
|
+
assert.ok(reopenedPanel);
|
|
2309
|
+
assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
|
|
2310
|
+
// Rendering an existing panel must not resolve Git or read the pin again.
|
|
2311
|
+
setProfilePinWorktreeResolverForTesting(() => { throw new Error("unexpected pin read during render"); });
|
|
2312
|
+
assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /pin\s+local: team/);
|
|
2313
|
+
assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
|
|
2317
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
2318
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2319
|
+
const commonDir = join(fixture.root, "git-\x1b]52;c;payload\x07-common");
|
|
2320
|
+
const localPath = join(commonDir, "gentle-ai", "profile-pin.json");
|
|
2321
|
+
writeProfilePinSync(localPath, "team");
|
|
2322
|
+
setProfilePinWorktreeResolverForTesting(() => ({ root: fixture.root, commonDir }));
|
|
2323
|
+
|
|
2324
|
+
const note = __testing.profilePinScopeNote(fixture.root);
|
|
2325
|
+
assert.ok(note);
|
|
2326
|
+
assert.doesNotMatch(note, /[\x00-\x1f\x7f-\x9f]/);
|
|
2327
|
+
assert.doesNotMatch(note, /payload/);
|
|
2328
|
+
assert.match(note, /profile-pin\.json/);
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
test("P declares the profile in the worktree so the routing can be committed", async (t) => {
|
|
2332
|
+
const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
2333
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2334
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2335
|
+
fixture.onInput((panel) => {
|
|
2336
|
+
if (firstPanel === undefined) {
|
|
2337
|
+
firstPanel = panel;
|
|
2338
|
+
panel.handleInput("P");
|
|
2339
|
+
panel.handleInput("\x1b");
|
|
2340
|
+
} else {
|
|
2341
|
+
panel.handleInput("\x1b");
|
|
2342
|
+
}
|
|
2343
|
+
});
|
|
2344
|
+
await fixture.run("gentle:profiles");
|
|
2345
|
+
|
|
2346
|
+
assert.equal(
|
|
2347
|
+
readFileSync(repoPinPath, "utf8"),
|
|
2348
|
+
`${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
|
|
2349
|
+
);
|
|
2350
|
+
assert.equal(existsSync(localPinPath), false, "P writes the worktree declaration only");
|
|
2351
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /declared profile "team" for this worktree in /);
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
test("a stale pin is named as missing instead of silently changing nothing", async (t) => {
|
|
2355
|
+
const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
2356
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2357
|
+
writePin(localPinPath, "deleted-profile");
|
|
2358
|
+
let rendered: string | undefined;
|
|
2359
|
+
fixture.onInput((panel) => {
|
|
2360
|
+
if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
|
|
2361
|
+
panel.handleInput("\x1b");
|
|
2362
|
+
});
|
|
2363
|
+
await fixture.run("gentle:profiles");
|
|
2364
|
+
assert.match(rendered ?? "", /pin\s+local: deleted-profile \(missing from this store\)/);
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
test("pinning outside a Git worktree warns and writes nothing", async (t) => {
|
|
2368
|
+
const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
|
|
2369
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2370
|
+
// The fixture installs a sandbox identity; this session has no worktree at all.
|
|
2371
|
+
setProfilePinWorktreeResolverForTesting(() => undefined);
|
|
2372
|
+
let visits = 0;
|
|
2373
|
+
fixture.onInput((panel) => {
|
|
2374
|
+
assert.ok(++visits <= 2, "outside-worktree fixture repeatedly reopens and pins the panel");
|
|
2375
|
+
// A completed panel ignores Escape; close the next visit instead.
|
|
2376
|
+
panel.handleInput(visits === 1 ? "p" : "\x1b");
|
|
2377
|
+
});
|
|
2378
|
+
await fixture.run("gentle:profiles");
|
|
2379
|
+
assert.equal(existsSync(localPinPath), false);
|
|
2380
|
+
assert.equal(existsSync(repoPinPath), false);
|
|
2381
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /not inside a Git worktree/);
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
test("pressing p again on the pinned profile removes the clone pin", async (t) => {
|
|
2385
|
+
const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
2386
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2387
|
+
writePin(localPinPath, "team");
|
|
2388
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2389
|
+
fixture.onInput((panel) => {
|
|
2390
|
+
if (firstPanel === undefined) {
|
|
2391
|
+
firstPanel = panel;
|
|
2392
|
+
panel.handleInput("p");
|
|
2393
|
+
panel.handleInput("\x1b");
|
|
2394
|
+
} else {
|
|
2395
|
+
panel.handleInput("\x1b");
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
await fixture.run("gentle:profiles");
|
|
2399
|
+
assert.equal(existsSync(localPinPath), false, "a second p removes the layer instead of rewriting it");
|
|
2400
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /removed the local pin for this clone/);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
test("the profile list marks the layer that wins with (pinned)", async (t) => {
|
|
2404
|
+
const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
2405
|
+
writeStore({
|
|
2406
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2407
|
+
other: { worker: { model: "openai/beta" } },
|
|
2408
|
+
}, "team");
|
|
2409
|
+
writePin(localPinPath, "other");
|
|
2410
|
+
let rendered: string | undefined;
|
|
2411
|
+
fixture.onInput((panel) => {
|
|
2412
|
+
if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
|
|
2413
|
+
panel.handleInput("\x1b");
|
|
2414
|
+
});
|
|
2415
|
+
await fixture.run("gentle:profiles");
|
|
2416
|
+
assert.match(rendered ?? "", /other \(pinned\)/);
|
|
2417
|
+
assert.doesNotMatch(rendered ?? "", /team \(pinned\)/);
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
test("an invalid pin file is surfaced by the panel instead of reading as no pin", async (t) => {
|
|
2421
|
+
const { fixture, localPinPath, writeStore } = profilesStoreFixture(t);
|
|
2422
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
2423
|
+
mkdirSync(dirname(localPinPath), { recursive: true });
|
|
2424
|
+
writeFileSync(localPinPath, "{ not json\n");
|
|
2425
|
+
let rendered: string | undefined;
|
|
2426
|
+
fixture.onInput((panel) => {
|
|
2427
|
+
if (rendered === undefined) rendered = stripAnsi(renderComponent(panel));
|
|
2428
|
+
panel.handleInput("\x1b");
|
|
2429
|
+
});
|
|
2430
|
+
await fixture.run("gentle:profiles");
|
|
2431
|
+
assert.match(rendered ?? "", /invalid pin file/);
|
|
2432
|
+
assert.doesNotMatch(rendered ?? "", /\(missing from this store\)/);
|
|
2433
|
+
assert.equal(readFileSync(localPinPath, "utf8"), "{ not json\n", "reading never rewrites a broken pin");
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
test("applying a profile in a pinned repository re-pins the clone and writes no global routing", async (t) => {
|
|
2437
|
+
const { fixture, settingsPath, localPinPath, repoPinPath, writeStore, writeSettings, writePin } = profilesStoreFixture(t);
|
|
2438
|
+
writeSettings();
|
|
2439
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2440
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2441
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/sentinel" } }, null, 2)}\n`);
|
|
2442
|
+
const modelsBefore = readFileSync(fixture.globalPath, "utf8");
|
|
2443
|
+
// "team" is listed first and is selected, so Enter applies it while "pinned" is the
|
|
2444
|
+
// profile this repository currently resolves.
|
|
2445
|
+
writeStore({
|
|
2446
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2447
|
+
pinned: { worker: { model: "openai/beta" } },
|
|
2448
|
+
}, "pinned");
|
|
2449
|
+
writePin(localPinPath, "pinned");
|
|
2450
|
+
applyOnce(fixture);
|
|
2451
|
+
await fixture.run("gentle:profiles");
|
|
2452
|
+
|
|
2453
|
+
assert.equal(
|
|
2454
|
+
readFileSync(localPinPath, "utf8"),
|
|
2455
|
+
`${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
|
|
2456
|
+
"the clone is re-pinned to the applied profile",
|
|
2457
|
+
);
|
|
2458
|
+
assert.equal(existsSync(repoPinPath), false, "a committed declaration is never written by an apply");
|
|
2459
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
|
|
2460
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
|
|
2461
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized routing was written");
|
|
2462
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
test("deleting a profile is refused when a non-winning pin layer names it", async (t) => {
|
|
2466
|
+
const { fixture, localPinPath, repoPinPath, writeStore, writePin } = profilesStoreFixture(t);
|
|
2467
|
+
writeStore({
|
|
2468
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2469
|
+
other: { worker: { model: "openai/beta" } },
|
|
2470
|
+
}, "team");
|
|
2471
|
+
// The local pin wins with "other"; the declaration names "team", a lower layer that
|
|
2472
|
+
// does not win but still names a live profile for this repository.
|
|
2473
|
+
writePin(localPinPath, "other");
|
|
2474
|
+
writePin(repoPinPath, "team");
|
|
2475
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2476
|
+
fixture.onInput((panel) => {
|
|
2477
|
+
if (firstPanel === undefined) {
|
|
2478
|
+
firstPanel = panel;
|
|
2479
|
+
panel.handleInput("x");
|
|
2480
|
+
panel.handleInput("\x1b");
|
|
2481
|
+
} else {
|
|
2482
|
+
panel.handleInput("\x1b");
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
await fixture.run("gentle:profiles");
|
|
2486
|
+
assert.equal(existsSync(repoPinPath), true, "the declaration survives the refused delete");
|
|
2487
|
+
assert.match(fixture.notifications.at(-1)?.message ?? "", /is pinned for this repository/);
|
|
2488
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2489
|
+
assert.ok(store.profiles.team, "the profile is not deleted");
|
|
2490
|
+
assert.ok(store.profiles.other, "the other profile is untouched");
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
test("a rename follows the clone pin and leaves the committed declaration naming the old profile", () => {
|
|
2494
|
+
const base = mkdtempSync(join(tmpdir(), "gentle-pi-pin-rename-"));
|
|
2495
|
+
try {
|
|
2496
|
+
const renameRoot = join(base, "worktree");
|
|
2497
|
+
const commonDir = join(base, "git-common");
|
|
2498
|
+
mkdirSync(renameRoot, { recursive: true });
|
|
2499
|
+
setProfilePinWorktreeResolverForTesting(() => ({ root: renameRoot, commonDir }));
|
|
2500
|
+
const localPath = join(commonDir, "gentle-ai", "profile-pin.json");
|
|
2501
|
+
const repoPath = join(renameRoot, ".pi", "gentle-ai", "profile.json");
|
|
2502
|
+
writeProfilePinSync(localPath, "old");
|
|
2503
|
+
writeProfilePinSync(repoPath, "old");
|
|
2504
|
+
const follow = __testing.followRenamedPin(renameRoot, "old", "new");
|
|
2505
|
+
assert.deepEqual(follow.followed, [localPath]);
|
|
2506
|
+
assert.deepEqual(follow.stillDeclared, [repoPath]);
|
|
2507
|
+
assert.equal(JSON.parse(readFileSync(localPath, "utf8")).profile, "new");
|
|
2508
|
+
assert.equal(JSON.parse(readFileSync(repoPath, "utf8")).profile, "old", "the tracked declaration is never rewritten");
|
|
2509
|
+
} finally {
|
|
2510
|
+
setProfilePinWorktreeResolverForTesting();
|
|
2511
|
+
rmSync(base, { recursive: true, force: true });
|
|
2512
|
+
}
|
|
2513
|
+
});
|