gentle-pi 2.4.0 → 2.6.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import test from "node:test";
|
|
@@ -12,10 +13,14 @@ import type {
|
|
|
12
13
|
Theme,
|
|
13
14
|
ToolCallEventResult,
|
|
14
15
|
} from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
16
|
-
import
|
|
16
|
+
import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
17
|
+
import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
|
|
18
|
+
import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
19
|
+
import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
20
|
+
import { installPackageAssets } from "../lib/sdd-preflight.ts";
|
|
17
21
|
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
18
22
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
23
|
+
import { cardBody, cardHint, cardTitle, cardTone } from "./gentle-card-text.ts";
|
|
19
24
|
|
|
20
25
|
initTheme("dark");
|
|
21
26
|
|
|
@@ -60,6 +65,146 @@ function lifecycleContext(overrides: Record<string, unknown> = {}): Record<strin
|
|
|
60
65
|
};
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
// gentle-pi#874: the provider's fresh_target_ready offer. The argument order
|
|
69
|
+
// mirrors the live selectorless STATUS a clean, fully committed worktree
|
|
70
|
+
// receives: it names the merge-base it wants so a plain START can adopt it.
|
|
71
|
+
function offeredCommittedRangeStatus(baseRef: string, baseTree: string, candidateTree: string, paths: readonly string[] = ["app.ts"]): ReviewStatusV3 {
|
|
72
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
73
|
+
return {
|
|
74
|
+
contract: "gentle-ai.review-integration/v2",
|
|
75
|
+
applicability: "unrelated",
|
|
76
|
+
action: "start",
|
|
77
|
+
replayability: "not_replayable",
|
|
78
|
+
targetIdentity,
|
|
79
|
+
projection: {
|
|
80
|
+
schema: "gentle-ai.review-candidate-projection/v1",
|
|
81
|
+
kind: "base-diff",
|
|
82
|
+
projection: "workspace",
|
|
83
|
+
baseTree,
|
|
84
|
+
initialReviewTree: candidateTree,
|
|
85
|
+
currentCandidateTree: candidateTree,
|
|
86
|
+
pathsDigest: `sha256:${"b".repeat(64)}`,
|
|
87
|
+
paths: [...paths],
|
|
88
|
+
intendedUntracked: [],
|
|
89
|
+
intendedUntrackedProof: `sha256:${"c".repeat(64)}`,
|
|
90
|
+
initialSnapshotIdentity: `sha256:${"d".repeat(64)}`,
|
|
91
|
+
currentSnapshotIdentity: `sha256:${"d".repeat(64)}`,
|
|
92
|
+
},
|
|
93
|
+
candidates: [],
|
|
94
|
+
nextTransition: {
|
|
95
|
+
kind: "execute",
|
|
96
|
+
reasonCode: "fresh_target_ready",
|
|
97
|
+
execute: {
|
|
98
|
+
operation: "review.start",
|
|
99
|
+
arguments: [
|
|
100
|
+
{ name: "target", value: targetIdentity, token: `--target=${targetIdentity}` },
|
|
101
|
+
{ name: "target-evidence", value: `v1:base-diff:workspace:${baseTree}:${candidateTree}:sha256:${"e".repeat(64)}`, token: `--target-evidence=v1:base-diff:workspace:${baseTree}:${candidateTree}:sha256:${"e".repeat(64)}` },
|
|
102
|
+
{ name: "projection", value: "workspace", token: "--projection=workspace" },
|
|
103
|
+
{ name: "base-ref", value: baseRef, token: `--base-ref=${baseRef}` },
|
|
104
|
+
{ name: "committed-only", value: "true", token: "--committed-only=true" },
|
|
105
|
+
{ name: "lineage", value: "review-offered", token: "--lineage=review-offered" },
|
|
106
|
+
{ name: "agent", value: "pi", token: "--agent=pi" },
|
|
107
|
+
{ name: "consent", value: "relay", token: "--consent=relay" },
|
|
108
|
+
],
|
|
109
|
+
preconditions: [],
|
|
110
|
+
binding: { targetIdentity },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
raw: { schema: "gentle-ai.review-integration.status/v5" },
|
|
114
|
+
} as unknown as ReviewStatusV3;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The clean, fully committed worktree STATUS with no committed-range offer:
|
|
118
|
+
// the current-changes candidate view a plain START builds today.
|
|
119
|
+
function cleanWorkspaceStatus(headTree: string, nextTransition?: ReviewStatusV3["nextTransition"]): ReviewStatusV3 {
|
|
120
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
121
|
+
return {
|
|
122
|
+
contract: "gentle-ai.review-integration/v2",
|
|
123
|
+
applicability: "unrelated",
|
|
124
|
+
action: "start",
|
|
125
|
+
replayability: "not_replayable",
|
|
126
|
+
targetIdentity,
|
|
127
|
+
projection: {
|
|
128
|
+
schema: "gentle-ai.review-candidate-projection/v1",
|
|
129
|
+
kind: "current-changes",
|
|
130
|
+
projection: "workspace",
|
|
131
|
+
baseTree: headTree,
|
|
132
|
+
initialReviewTree: headTree,
|
|
133
|
+
currentCandidateTree: headTree,
|
|
134
|
+
pathsDigest: `sha256:${"b".repeat(64)}`,
|
|
135
|
+
paths: [],
|
|
136
|
+
intendedUntracked: [],
|
|
137
|
+
intendedUntrackedProof: `sha256:${"c".repeat(64)}`,
|
|
138
|
+
initialSnapshotIdentity: `sha256:${"d".repeat(64)}`,
|
|
139
|
+
currentSnapshotIdentity: `sha256:${"d".repeat(64)}`,
|
|
140
|
+
},
|
|
141
|
+
candidates: [],
|
|
142
|
+
...(nextTransition === undefined ? {} : { nextTransition }),
|
|
143
|
+
raw: { schema: "gentle-ai.review-integration.status/v5" },
|
|
144
|
+
} as unknown as ReviewStatusV3;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function startedReviewResult(): Record<string, unknown> {
|
|
148
|
+
return { lineageId: "adopted-range", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface ReviewStartRepository {
|
|
152
|
+
cwd: string;
|
|
153
|
+
baseCommit: string;
|
|
154
|
+
baseTree: string;
|
|
155
|
+
headTree: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A hermetic two-commit repository whose candidate range is a real committed
|
|
159
|
+
// change, so both the adopted and explicit base-ref paths resolve through the
|
|
160
|
+
// real candidate-view guard.
|
|
161
|
+
function reviewRepository(t: test.TestContext): ReviewStartRepository {
|
|
162
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-review-start-")));
|
|
163
|
+
t.after(() => {
|
|
164
|
+
try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
|
|
165
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
166
|
+
});
|
|
167
|
+
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
|
|
168
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
|
|
169
|
+
execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
|
|
170
|
+
execFileSync("git", ["-c", "user.name=Review Test", "-c", "user.email=review@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
|
|
171
|
+
const baseCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
|
|
172
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
173
|
+
execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
|
|
174
|
+
execFileSync("git", ["-c", "user.name=Review Test", "-c", "user.email=review@example.invalid", "commit", "-m", "candidate"], { cwd, stdio: "ignore" });
|
|
175
|
+
return {
|
|
176
|
+
cwd,
|
|
177
|
+
baseCommit,
|
|
178
|
+
baseTree: execFileSync("git", ["rev-parse", `${baseCommit}^{tree}`], { cwd, encoding: "utf8" }).trim(),
|
|
179
|
+
headTree: execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd, encoding: "utf8" }).trim(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test("missing package-local binaries give a direct recovery without attributing the cause to lifecycle scripts", async () => {
|
|
184
|
+
const result = await __testing.executeReviewControllerOperation(
|
|
185
|
+
{ operation: "inspect" },
|
|
186
|
+
process.cwd(),
|
|
187
|
+
{
|
|
188
|
+
targetStatus: async () => {
|
|
189
|
+
throw new NativeReviewCliError(
|
|
190
|
+
NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING,
|
|
191
|
+
"review/status",
|
|
192
|
+
false,
|
|
193
|
+
false,
|
|
194
|
+
"package binary missing",
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
} as unknown as NativeReviewCli,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
assert.equal(result.outcome, "native-status-package-binary-missing");
|
|
201
|
+
assert.equal(result.recovery_command, "node scripts/install-gentle-ai.mjs");
|
|
202
|
+
assert.match(String(result.next_action), /installed gentle-pi package directory/);
|
|
203
|
+
assert.match(String(result.next_action), /GENTLE_PI_SKIP_GENTLE_AI_INSTALL/);
|
|
204
|
+
assert.match(String(result.next_action), /remove or unset it before/);
|
|
205
|
+
assert.match(String(result.reason), /does not prove install lifecycle scripts were disabled/);
|
|
206
|
+
});
|
|
207
|
+
|
|
63
208
|
test("registered Gentle Review tools render reusable rose lifecycle call rows", () => {
|
|
64
209
|
const tools = registeredGentleTools();
|
|
65
210
|
const cases = [
|
|
@@ -112,10 +257,10 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
|
|
|
112
257
|
assert.strictEqual(initial, running);
|
|
113
258
|
assert.strictEqual(running, completed);
|
|
114
259
|
assert.strictEqual(completed, failed);
|
|
115
|
-
assert.equal(initialText,
|
|
116
|
-
assert.equal(runningText,
|
|
117
|
-
assert.equal(completedText,
|
|
118
|
-
assert.equal(failedText,
|
|
260
|
+
assert.equal(cardTitle(initialText), `🌹︎ Gentle AI · running · ${operationPath}`); assert.equal(cardTone(initialText), "warning");
|
|
261
|
+
assert.equal(cardTitle(runningText), `🌹︎ Gentle AI · running · ${operationPath}`); assert.equal(cardTone(runningText), "warning");
|
|
262
|
+
assert.equal(cardTitle(completedText), `🌹︎ Gentle AI · completed · ${operationPath}`); assert.equal(cardTone(completedText), "success");
|
|
263
|
+
assert.equal(cardTitle(failedText), `🌹︎ Gentle AI · failed · ${operationPath}`); assert.equal(cardTone(failedText), "error");
|
|
119
264
|
assert.doesNotMatch(renderComponent(failed), /future-operation|secret|private/);
|
|
120
265
|
for (const forbiddenValue of ["lineage-id", "binding-id", "sha256:hash-value", "secret-value", "arbitrary-value"]) {
|
|
121
266
|
assert.doesNotMatch(failedText, new RegExp(forbiddenValue));
|
|
@@ -159,20 +304,317 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
|
|
|
159
304
|
{ expanded: false, isPartial: false, isError: true },
|
|
160
305
|
]) {
|
|
161
306
|
const collapsed = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, options, lifecycleTheme, {}));
|
|
162
|
-
assert.
|
|
163
|
-
assert.
|
|
307
|
+
assert.match(cardBody(collapsed), /\d+ lines?\b/, `${name} collapsed output must contain one expand hint`);
|
|
308
|
+
assert.match(cardBody(collapsed), /\d+ lines?\b/, `${name} collapsed output must start with the hint`);
|
|
164
309
|
assert.doesNotMatch(collapsed, /safe result|lineage=secret|private/);
|
|
165
310
|
}
|
|
166
311
|
const expanded = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, { expanded: true, isPartial: false, isError: true }, lifecycleTheme, {}));
|
|
167
|
-
assert.equal(expanded.split("\n")[0], "safe result");
|
|
312
|
+
assert.equal(cardBody(expanded).split("\n")[0], "safe result");
|
|
168
313
|
assert.match(expanded, /safe result/);
|
|
169
314
|
assert.match(expanded, /lineage=secret body=private/);
|
|
170
315
|
assert.doesNotMatch(expanded, /to expand/);
|
|
171
|
-
assert.doesNotMatch(expanded, /\x1b\[/);
|
|
316
|
+
assert.doesNotMatch(cardBody(expanded), /\x1b\[/);
|
|
172
317
|
const nonText = renderComponent(tool.renderResult({ content: [{ type: "image", data: "opaque", mimeType: "image/png" }] }, { expanded: true, isPartial: false }, lifecycleTheme, {}));
|
|
173
|
-
assert.equal(nonText, "");
|
|
318
|
+
assert.equal(cardBody(nonText), "");
|
|
174
319
|
const empty = renderComponent(tool.renderResult({ content: [{ type: "text", text: "" }] }, { expanded: false, isPartial: false }, lifecycleTheme, {}));
|
|
175
|
-
assert.equal(empty, "");
|
|
320
|
+
assert.equal(cardBody(empty), "");
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
interface RoutingConsumerPanel {
|
|
325
|
+
render(width: number): string[];
|
|
326
|
+
handleInput(data: string): void;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
330
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-routing-consumers-"));
|
|
331
|
+
const configHome = join(root, "global");
|
|
332
|
+
const agentHome = join(root, "agent-home");
|
|
333
|
+
const projectPath = join(root, ".pi", "gentle-ai", "models.json");
|
|
334
|
+
const globalPath = join(configHome, "models.json");
|
|
335
|
+
const exportPath = join(configHome, "models.export.json");
|
|
336
|
+
for (const dir of [dirname(projectPath), join(root, "agents"), join(agentHome, "agents"), join(agentHome, "subagents")]) {
|
|
337
|
+
mkdirSync(dir, { recursive: true });
|
|
338
|
+
}
|
|
339
|
+
for (const name of agents) {
|
|
340
|
+
writeMarkdown(join(root, ".pi", "agents", `${name}.md`), `---\nname: ${name}\ndescription: Worker\n---\nbody\n`);
|
|
341
|
+
}
|
|
342
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
343
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
344
|
+
const previousHome = process.env.HOME;
|
|
345
|
+
const previousUserProfile = process.env.USERPROFILE;
|
|
346
|
+
const isolatedHome = join(root, "home");
|
|
347
|
+
mkdirSync(isolatedHome, { recursive: true });
|
|
348
|
+
// Isolate homedir-based discovery on POSIX and Windows.
|
|
349
|
+
// Package-sibling legacy agents remain subject to discovery assertions.
|
|
350
|
+
process.env.HOME = isolatedHome;
|
|
351
|
+
process.env.USERPROFILE = isolatedHome;
|
|
352
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
353
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
354
|
+
t.after(() => {
|
|
355
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
356
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
357
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
358
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
359
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
360
|
+
else process.env.HOME = previousHome;
|
|
361
|
+
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
362
|
+
else process.env.USERPROFILE = previousUserProfile;
|
|
363
|
+
rmSync(root, { recursive: true, force: true });
|
|
364
|
+
});
|
|
365
|
+
const commands = new Map<string, Parameters<ExtensionAPI["registerCommand"]>[1]>();
|
|
366
|
+
createGentleAiExtension({ nativeReviewCli: null })({
|
|
367
|
+
on() {},
|
|
368
|
+
registerTool() {},
|
|
369
|
+
registerCommand(name, command) { commands.set(name, command); },
|
|
370
|
+
} as ExtensionAPI);
|
|
371
|
+
const notifications: Array<{ message: string; severity: string }> = [];
|
|
372
|
+
// The profiles panel reads the terminal rows to size its full-screen frame, so
|
|
373
|
+
// the fake UI hands every factory a TUI-shaped stand-in with a mutable height.
|
|
374
|
+
const fixtureTui = { terminal: { rows: 24 }, requestRender() {} };
|
|
375
|
+
let panelVisits = 0;
|
|
376
|
+
const panels: string[] = [];
|
|
377
|
+
let onPanel = () => ({ type: "cancel", config: {} });
|
|
378
|
+
let onInput: ((panel: RoutingConsumerPanel) => void) | undefined;
|
|
379
|
+
const ctx = {
|
|
380
|
+
cwd: root,
|
|
381
|
+
hasUI: true,
|
|
382
|
+
modelRegistry: { getAvailable: async () => [
|
|
383
|
+
{ provider: "openai", id: "alpha" },
|
|
384
|
+
{ provider: "openai", id: "beta" },
|
|
385
|
+
] },
|
|
386
|
+
ui: {
|
|
387
|
+
notify(message: string, severity: string) { notifications.push({ message, severity }); },
|
|
388
|
+
custom: async (factory: (tui: unknown, theme: Theme, keybindings: unknown, done: (result: unknown) => void) => RoutingConsumerPanel) => {
|
|
389
|
+
let result: unknown;
|
|
390
|
+
const panel = factory(fixtureTui, { fg: (_color: string, text: string) => text } as unknown as Theme, undefined, (value) => { result = value; });
|
|
391
|
+
panels.push(stripAnsi(renderComponent(panel)));
|
|
392
|
+
panelVisits += 1;
|
|
393
|
+
if (onInput) {
|
|
394
|
+
onInput(panel);
|
|
395
|
+
assert.notEqual(result, undefined, "panel input must finish the interaction");
|
|
396
|
+
return result;
|
|
397
|
+
}
|
|
398
|
+
return onPanel();
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
} as unknown as Parameters<Parameters<ExtensionAPI["registerCommand"]>[1]["handler"]>[1];
|
|
402
|
+
return {
|
|
403
|
+
root, agentHome, configHome, projectPath, globalPath, exportPath, notifications, panels,
|
|
404
|
+
tui: fixtureTui as { terminal: { rows: number } },
|
|
405
|
+
panelVisits: () => panelVisits,
|
|
406
|
+
onPanel(action: typeof onPanel) { onPanel = action; },
|
|
407
|
+
onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
|
|
408
|
+
run: (name: string) => commands.get(name)!.handler("", ctx),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
test("models saves and clears independent provider review roles without local artifacts", async (t) => {
|
|
413
|
+
const fixture = routingConsumerFixture(t, []);
|
|
414
|
+
const roles = ["review-refuter", "review-validator"];
|
|
415
|
+
assert.deepEqual(
|
|
416
|
+
__testing.listDiscoverableAgents(fixture.root).map((agent) => agent.name),
|
|
417
|
+
[],
|
|
418
|
+
"requires zero discovered agents; check package-sibling legacy agent directories",
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const assertNoLocalArtifacts = () => {
|
|
422
|
+
for (const base of [join(fixture.root, ".pi"), fixture.agentHome]) {
|
|
423
|
+
assert.equal(existsSync(join(base, "subagents.json")), false);
|
|
424
|
+
for (const dir of ["agents", "subagents"]) {
|
|
425
|
+
for (const role of roles) {
|
|
426
|
+
assert.equal(existsSync(join(base, dir, `${role}.md`)), false);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
fixture.onInput((panel) => {
|
|
432
|
+
const initial = renderComponent(panel);
|
|
433
|
+
for (const role of roles) {
|
|
434
|
+
assert.equal(initial.split(role).length - 1, 1);
|
|
435
|
+
}
|
|
436
|
+
assert.match(initial, /Pi persisted default model/);
|
|
437
|
+
assert.match(initial, /Pi persisted default effort/);
|
|
438
|
+
for (const [index, model] of ["alpha", "beta"].entries()) {
|
|
439
|
+
panel.handleInput("j");
|
|
440
|
+
panel.handleInput("\r");
|
|
441
|
+
assert.match(renderComponent(panel), /Pi persisted default model/);
|
|
442
|
+
assert.doesNotMatch(renderComponent(panel), /Inherit active\/default model/);
|
|
443
|
+
for (const character of model) panel.handleInput(character);
|
|
444
|
+
panel.handleInput("\r");
|
|
445
|
+
panel.handleInput("e");
|
|
446
|
+
assert.match(renderComponent(panel), /Pi persisted default effort/);
|
|
447
|
+
assert.doesNotMatch(renderComponent(panel), /Inherit effort/);
|
|
448
|
+
for (let step = 0; step <= index; step++) panel.handleInput("j");
|
|
449
|
+
panel.handleInput("\r");
|
|
450
|
+
}
|
|
451
|
+
panel.handleInput("\x13");
|
|
452
|
+
});
|
|
453
|
+
await fixture.run("gentle:models");
|
|
454
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
|
|
455
|
+
"review-refuter": { model: "openai/alpha", thinking: "off" },
|
|
456
|
+
"review-validator": { model: "openai/beta", thinking: "minimal" },
|
|
457
|
+
});
|
|
458
|
+
assertNoLocalArtifacts();
|
|
459
|
+
|
|
460
|
+
fixture.onInput((panel) => {
|
|
461
|
+
panel.handleInput("j");
|
|
462
|
+
panel.handleInput("i");
|
|
463
|
+
panel.handleInput("\x13");
|
|
464
|
+
});
|
|
465
|
+
await fixture.run("gentle:models");
|
|
466
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
|
|
467
|
+
"review-refuter": {},
|
|
468
|
+
"review-validator": { model: "openai/beta", thinking: "minimal" },
|
|
469
|
+
});
|
|
470
|
+
assertNoLocalArtifacts();
|
|
471
|
+
|
|
472
|
+
fixture.onInput((panel) => {
|
|
473
|
+
panel.handleInput("j");
|
|
474
|
+
panel.handleInput("j");
|
|
475
|
+
panel.handleInput("i");
|
|
476
|
+
panel.handleInput("\x13");
|
|
477
|
+
});
|
|
478
|
+
await fixture.run("gentle:models");
|
|
479
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
|
|
480
|
+
"review-refuter": {},
|
|
481
|
+
"review-validator": {},
|
|
482
|
+
});
|
|
483
|
+
assertNoLocalArtifacts();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("provider review roles skip migration and both projection paths even when discoverable", async (t) => {
|
|
487
|
+
const roles = ["review-refuter", "review-validator"];
|
|
488
|
+
const fixture = routingConsumerFixture(t, [...roles, "worker"]);
|
|
489
|
+
assert.deepEqual(
|
|
490
|
+
__testing.listDiscoverableAgents(fixture.root).map((agent) => agent.name).sort(),
|
|
491
|
+
[...roles, "worker"].sort(),
|
|
492
|
+
"requires only seeded agents; check package-sibling legacy agent directories",
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const rolePaths = roles.map((role) => join(fixture.root, ".pi", "agents", `${role}.md`));
|
|
496
|
+
const originals = rolePaths.map((path) => readFileSync(path, "utf8"));
|
|
497
|
+
const profilePaths = [
|
|
498
|
+
join(fixture.root, ".pi", "subagents.json"),
|
|
499
|
+
join(fixture.agentHome, "subagents.json"),
|
|
500
|
+
];
|
|
501
|
+
const profile = `${JSON.stringify({ model_profiles: {
|
|
502
|
+
"review-refuter": "existing-refuter",
|
|
503
|
+
"review-validator": "existing-validator",
|
|
504
|
+
} }, null, 2)}\n`;
|
|
505
|
+
for (const path of profilePaths) writeMarkdown(path, profile);
|
|
506
|
+
const assignments = {
|
|
507
|
+
"review-refuter": { model: "openai/alpha", thinking: "off" as const },
|
|
508
|
+
"review-validator": { model: "openai/beta", thinking: "minimal" as const },
|
|
509
|
+
};
|
|
510
|
+
writeMarkdown(join(fixture.root, ".pi", "settings.json"), JSON.stringify({
|
|
511
|
+
subagents: { agentOverrides: assignments },
|
|
512
|
+
}));
|
|
513
|
+
const assertReservedUnchanged = () => {
|
|
514
|
+
rolePaths.forEach((path, index) => assert.equal(readFileSync(path, "utf8"), originals[index]));
|
|
515
|
+
for (const path of profilePaths) assert.equal(readFileSync(path, "utf8"), profile);
|
|
516
|
+
};
|
|
517
|
+
await fixture.run("gentle:models");
|
|
518
|
+
assertReservedUnchanged();
|
|
519
|
+
for (const role of roles) assert.equal(fixture.panels[0].split(role).length - 1, 1);
|
|
520
|
+
assert.match(fixture.panels[0], /worker\s+model=inherit, effort=inherit/);
|
|
521
|
+
for (const apply of [applyModelConfig, applyModelConfigAsync]) {
|
|
522
|
+
await apply(fixture.root, assignments);
|
|
523
|
+
assertReservedUnchanged();
|
|
524
|
+
await apply(fixture.root, { "review-refuter": {}, "review-validator": {} });
|
|
525
|
+
assertReservedUnchanged();
|
|
526
|
+
}
|
|
527
|
+
await applyModelConfigAsync(fixture.root, { worker: { model: "openai/alpha" } });
|
|
528
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
|
|
529
|
+
assert.ok(JSON.parse(readFileSync(profilePaths[0], "utf8")).model_profiles.worker);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("models rejects invalid project routing with its selected source path", async (t) => {
|
|
533
|
+
const fixture = routingConsumerFixture(t);
|
|
534
|
+
writeFileSync(fixture.projectPath, "[]");
|
|
535
|
+
await fixture.run("gentle:models");
|
|
536
|
+
assert.equal(fixture.notifications[0]?.severity, "warning");
|
|
537
|
+
assert.ok(fixture.notifications[0]?.message.includes(fixture.projectPath));
|
|
538
|
+
assert.equal(fixture.panelVisits(), 0);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => {
|
|
542
|
+
const fixture = routingConsumerFixture(t);
|
|
543
|
+
writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}');
|
|
544
|
+
fixture.onPanel(() => {
|
|
545
|
+
if (fixture.panelVisits() > 1) return { type: "cancel", config: {} };
|
|
546
|
+
writeFileSync(fixture.projectPath, "[]");
|
|
547
|
+
return { type: "export", config: {} };
|
|
548
|
+
});
|
|
549
|
+
await fixture.run("gentle:models");
|
|
550
|
+
assert.equal(fixture.notifications[0]?.severity, "warning");
|
|
551
|
+
assert.ok(fixture.notifications[0]?.message.includes(`Invalid model config: ${fixture.projectPath}`));
|
|
552
|
+
assert.equal(existsSync(fixture.exportPath), false);
|
|
553
|
+
assert.equal(existsSync(fixture.configHome), false);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("status reports invalid saved routing path instead of default agent routing", async (t) => {
|
|
557
|
+
const fixture = routingConsumerFixture(t);
|
|
558
|
+
writeFileSync(fixture.projectPath, "[]");
|
|
559
|
+
await fixture.run("gentle:status");
|
|
560
|
+
const report = fixture.notifications.at(-1)!;
|
|
561
|
+
assert.match(report.message, /Saved model routing: invalid/);
|
|
562
|
+
assert.ok(report.message.includes(fixture.projectPath));
|
|
563
|
+
assert.equal(report.severity, "warning");
|
|
564
|
+
assert.doesNotMatch(report.message, /worker: model=/);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("models exports missing, normalized project, and global-precedence saved routing", async (t) => {
|
|
568
|
+
for (const source of ["missing", "project", "global"] as const) {
|
|
569
|
+
await t.test(source, async (t) => {
|
|
570
|
+
const fixture = routingConsumerFixture(t);
|
|
571
|
+
if (source !== "missing") writeFileSync(fixture.projectPath, '{"worker":" openai/gpt-5 ","ignored":null}');
|
|
572
|
+
if (source === "global") writeMarkdown(fixture.globalPath, '{"worker":{"model":" anthropic/opus ","thinking":"high"}}');
|
|
573
|
+
fixture.onPanel(() => ({ type: fixture.panelVisits() === 1 ? "export" : "cancel", config: {} }));
|
|
574
|
+
await fixture.run("gentle:models");
|
|
575
|
+
const agents = source === "missing" ? {} : source === "project"
|
|
576
|
+
? { worker: { model: "openai/gpt-5" } }
|
|
577
|
+
: { worker: { model: "anthropic/opus", thinking: "high" } };
|
|
578
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, agents);
|
|
579
|
+
assert.equal(fixture.notifications[0]?.severity, "info");
|
|
580
|
+
assert.match(fixture.notifications[0]!.message, /exported/);
|
|
581
|
+
assert.equal(fixture.panelVisits(), 2);
|
|
582
|
+
await fixture.run("gentle:status");
|
|
583
|
+
const report = fixture.notifications.at(-1)!.message;
|
|
584
|
+
assert.ok(report.includes(`Saved model routing: ${source === "missing" ? "missing" : "valid"}`));
|
|
585
|
+
assert.ok(report.includes(`Global model config: ${source === "global" ? "present" : "missing"}`));
|
|
586
|
+
const expectedRouting = source === "missing" ? "inherit, effort=inherit"
|
|
587
|
+
: source === "project" ? "openai/gpt-5, effort=inherit" : "anthropic/opus, effort=high";
|
|
588
|
+
assert.ok(report.includes(`worker: model=${expectedRouting}`), report);
|
|
589
|
+
assert.ok(fixture.panels[0].includes(`model=${expectedRouting}`), fixture.panels[0]);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("invalid global routing overrides valid project in models, status, and export re-read", async (t) => {
|
|
595
|
+
for (const atExport of [false, true]) {
|
|
596
|
+
await t.test(atExport ? "invalidated during panel" : "invalid before panel", async (t) => {
|
|
597
|
+
const fixture = routingConsumerFixture(t);
|
|
598
|
+
writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}');
|
|
599
|
+
if (!atExport) writeMarkdown(fixture.globalPath, "[]");
|
|
600
|
+
fixture.onPanel(() => {
|
|
601
|
+
if (fixture.panelVisits() > 1) return { type: "cancel", config: {} };
|
|
602
|
+
writeMarkdown(fixture.globalPath, "[]");
|
|
603
|
+
return { type: "export", config: {} };
|
|
604
|
+
});
|
|
605
|
+
await fixture.run("gentle:models");
|
|
606
|
+
assert.equal(fixture.notifications[0]?.severity, "warning");
|
|
607
|
+
assert.ok(fixture.notifications[0]?.message.includes(fixture.globalPath));
|
|
608
|
+
assert.match(fixture.notifications[0]!.message, atExport ? /export failed/ : /cannot open model config/);
|
|
609
|
+
assert.equal(fixture.panelVisits(), atExport ? 2 : 0);
|
|
610
|
+
assert.equal(existsSync(fixture.exportPath), false);
|
|
611
|
+
await fixture.run("gentle:status");
|
|
612
|
+
const report = fixture.notifications.at(-1)!;
|
|
613
|
+
assert.match(report.message, /Global model config: present\nSaved model routing: invalid/);
|
|
614
|
+
assert.ok(report.message.includes(fixture.globalPath));
|
|
615
|
+
assert.doesNotMatch(report.message, /worker: model=/);
|
|
616
|
+
assert.equal(report.severity, "warning");
|
|
617
|
+
});
|
|
176
618
|
}
|
|
177
619
|
});
|
|
178
620
|
|
|
@@ -240,6 +682,21 @@ test("session startup reports invalid project routing without mutating the profi
|
|
|
240
682
|
assert.equal(warning!.severity, "warning");
|
|
241
683
|
assert.match(warning!.message, /skipped model config/);
|
|
242
684
|
assert.equal(readFileSync(profilePath, "utf8"), before);
|
|
685
|
+
|
|
686
|
+
writeFileSync(join(projectConfigDir, "models.json"), '{"worker":"openai/gpt-5"}');
|
|
687
|
+
const globalPath = join(configHome, "models.json");
|
|
688
|
+
writeFileSync(globalPath, "[]");
|
|
689
|
+
notifications.length = 0;
|
|
690
|
+
await sessionStart!({}, {
|
|
691
|
+
cwd: root,
|
|
692
|
+
hasUI: true,
|
|
693
|
+
ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
|
|
694
|
+
} as unknown as ExtensionContext);
|
|
695
|
+
const globalWarning = notifications.find((entry) => entry.message.includes(globalPath));
|
|
696
|
+
assert.ok(globalWarning, JSON.stringify(notifications));
|
|
697
|
+
assert.equal(globalWarning.severity, "warning");
|
|
698
|
+
assert.match(globalWarning.message, /skipped model config/);
|
|
699
|
+
assert.equal(readFileSync(profilePath, "utf8"), before);
|
|
243
700
|
});
|
|
244
701
|
|
|
245
702
|
test("agent discovery skips skills directories", async (t) => {
|
|
@@ -267,6 +724,82 @@ test("agent discovery skips skills directories", async (t) => {
|
|
|
267
724
|
);
|
|
268
725
|
});
|
|
269
726
|
|
|
727
|
+
test("managed routing timeout leaves its profile, agent, and manifest unchanged", (t) => {
|
|
728
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-managed-routing-timeout-"));
|
|
729
|
+
const agentHome = join(root, "agent-home");
|
|
730
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
731
|
+
t.after(() => {
|
|
732
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
733
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
734
|
+
rmSync(root, { recursive: true, force: true });
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
738
|
+
installPackageAssets(root, false, ["sdd"]);
|
|
739
|
+
const agentPath = join(agentHome, "agents", "sdd-apply.md");
|
|
740
|
+
const manifestPath = join(agentHome, "gentle-ai", "managed-assets.json");
|
|
741
|
+
const profilePath = join(agentHome, "subagents.json");
|
|
742
|
+
const profileBefore = "{\n \"unrelated\": true\n}\n";
|
|
743
|
+
writeFileSync(profilePath, profileBefore);
|
|
744
|
+
const agentBefore = readFileSync(agentPath, "utf8");
|
|
745
|
+
const manifestBefore = readFileSync(manifestPath, "utf8");
|
|
746
|
+
writeFileSync(
|
|
747
|
+
join(agentHome, "gentle-ai", "managed-assets.lock"),
|
|
748
|
+
JSON.stringify({ schemaVersion: 1, token: "foreign", pid: process.pid, createdAtMs: Date.now() }),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
assert.throws(
|
|
752
|
+
() => applyModelConfig(root, { "sdd-apply": { model: "test/managed", thinking: "high" } }),
|
|
753
|
+
/Timed out acquiring managed-assets lock file/i,
|
|
754
|
+
);
|
|
755
|
+
assert.equal(readFileSync(profilePath, "utf8"), profileBefore);
|
|
756
|
+
assert.equal(readFileSync(agentPath, "utf8"), agentBefore);
|
|
757
|
+
assert.equal(readFileSync(manifestPath, "utf8"), manifestBefore);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("a later alias keeps managed-root precedence and manifest ownership", (t) => {
|
|
761
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-agent-root-alias-"));
|
|
762
|
+
const agentHome = join(root, "agent-home");
|
|
763
|
+
const home = join(root, "home");
|
|
764
|
+
const cwd = join(root, "project");
|
|
765
|
+
const managed = join(agentHome, "agents");
|
|
766
|
+
const intervening = join(agentHome, "subagents");
|
|
767
|
+
const alias = join(home, ".agents");
|
|
768
|
+
const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
|
|
769
|
+
const previousHome = process.env.HOME;
|
|
770
|
+
const previousUserProfile = process.env.USERPROFILE;
|
|
771
|
+
t.after(() => {
|
|
772
|
+
if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
|
|
773
|
+
else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
|
|
774
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
775
|
+
else process.env.HOME = previousHome;
|
|
776
|
+
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
777
|
+
else process.env.USERPROFILE = previousUserProfile;
|
|
778
|
+
rmSync(root, { recursive: true, force: true });
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
782
|
+
process.env.HOME = home;
|
|
783
|
+
process.env.USERPROFILE = home;
|
|
784
|
+
installPackageAssets(cwd, false, ["sdd"]);
|
|
785
|
+
writeMarkdown(join(intervening, "sdd-apply.md"), "---\nname: sdd-apply\n---\nintervening override\n");
|
|
786
|
+
mkdirSync(home, { recursive: true });
|
|
787
|
+
try {
|
|
788
|
+
symlinkSync(managed, alias, process.platform === "win32" ? "junction" : "dir");
|
|
789
|
+
} catch (error) {
|
|
790
|
+
t.skip(`directory aliases unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const selected = __testing.listDiscoverableAgents(cwd).find((agent) => agent.name === "sdd-apply");
|
|
795
|
+
assert.equal(selected?.filePath, join(managed, "sdd-apply.md"));
|
|
796
|
+
applyModelConfig(cwd, { "sdd-apply": { model: "test/managed", thinking: "high" } });
|
|
797
|
+
const manifest = JSON.parse(readFileSync(join(agentHome, "gentle-ai", "managed-assets.json"), "utf8")) as { assets: Record<string, string> };
|
|
798
|
+
const routed = readFileSync(join(managed, "sdd-apply.md"), "utf8");
|
|
799
|
+
assert.match(routed, /^model: test\/managed$/m);
|
|
800
|
+
assert.equal(manifest.assets["agents/sdd-apply.md"], createHash("sha256").update(routed).digest("hex"));
|
|
801
|
+
});
|
|
802
|
+
|
|
270
803
|
test("runtime guidance keeps review policy out of the static orchestrator", () => {
|
|
271
804
|
const staticReferences = ["README.md", "skills/gentle-ai/SKILL.md"];
|
|
272
805
|
const forbiddenGenericRoutes = [
|
|
@@ -376,6 +909,151 @@ test("ordinary native capture exposes a registered schema and STATUS binding cop
|
|
|
376
909
|
assert.equal(launches, 1);
|
|
377
910
|
});
|
|
378
911
|
|
|
912
|
+
test("ordinary START adopts the committed-range selectors the provider just offered", async (t) => {
|
|
913
|
+
const { cwd, baseCommit, baseTree, headTree: candidateTree } = reviewRepository(t);
|
|
914
|
+
const starts: Array<Record<string, unknown>> = [];
|
|
915
|
+
const native = {
|
|
916
|
+
targetStatus: async () => offeredCommittedRangeStatus(baseCommit, baseTree, candidateTree),
|
|
917
|
+
start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
|
|
918
|
+
} as unknown as NativeReviewCli;
|
|
919
|
+
const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
|
|
920
|
+
assert.equal(result.operation, "start");
|
|
921
|
+
assert.equal(starts.length, 1);
|
|
922
|
+
assert.equal(starts[0]?.baseRef, baseCommit);
|
|
923
|
+
assert.equal(starts[0]?.committedOnly, true);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test("ordinary START keeps an explicit caller baseRef and ignores the offered selector", async (t) => {
|
|
927
|
+
const { cwd, baseCommit, baseTree, headTree: candidateTree } = reviewRepository(t);
|
|
928
|
+
const starts: Array<Record<string, unknown>> = [];
|
|
929
|
+
const native = {
|
|
930
|
+
// A shape-valid but never-invoked offer: the explicit caller value must win
|
|
931
|
+
// before this is ever considered.
|
|
932
|
+
targetStatus: async () => offeredCommittedRangeStatus("f".repeat(40), baseTree, candidateTree),
|
|
933
|
+
start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
|
|
934
|
+
} as unknown as NativeReviewCli;
|
|
935
|
+
const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef: baseCommit, committedOnly: true }) }, cwd, native);
|
|
936
|
+
assert.equal(result.operation, "start");
|
|
937
|
+
assert.equal(starts.length, 1);
|
|
938
|
+
assert.equal(starts[0]?.baseRef, baseCommit);
|
|
939
|
+
assert.equal(starts[0]?.committedOnly, true);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("ordinary START keeps today's invocation when STATUS offers no committed-range selector", async (t) => {
|
|
943
|
+
const { cwd, headTree } = reviewRepository(t);
|
|
944
|
+
const offeredWithoutBaseRef = cleanWorkspaceStatus(headTree, {
|
|
945
|
+
kind: "execute",
|
|
946
|
+
reasonCode: "fresh_target_ready",
|
|
947
|
+
execute: { operation: "review.start", arguments: [{ name: "projection", value: "workspace", token: "--projection=workspace" }], preconditions: [], binding: { targetIdentity: `sha256:${"a".repeat(64)}` } },
|
|
948
|
+
});
|
|
949
|
+
for (const [label, target] of [["no execute transition", cleanWorkspaceStatus(headTree)], ["no base-ref", offeredWithoutBaseRef]] as const) {
|
|
950
|
+
const starts: Array<Record<string, unknown>> = [];
|
|
951
|
+
const native = {
|
|
952
|
+
targetStatus: async () => target,
|
|
953
|
+
start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
|
|
954
|
+
} as unknown as NativeReviewCli;
|
|
955
|
+
const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
|
|
956
|
+
assert.equal(result.operation, "start", label);
|
|
957
|
+
assert.equal(starts.length, 1, label);
|
|
958
|
+
assert.equal(starts[0]?.baseRef, undefined, label);
|
|
959
|
+
assert.equal("committedOnly" in starts[0]!, false, label);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("ordinary START reports candidate-owner preparation failure as pre-native no mutation", async () => {
|
|
964
|
+
let nativeStarts = 0;
|
|
965
|
+
const target = {
|
|
966
|
+
contract: "gentle-ai.review-integration/v2",
|
|
967
|
+
applicability: "unrelated",
|
|
968
|
+
action: "start",
|
|
969
|
+
replayability: "not_replayable",
|
|
970
|
+
targetIdentity: "a".repeat(64),
|
|
971
|
+
projection: {
|
|
972
|
+
schema: "gentle-ai.review-candidate-projection/v1",
|
|
973
|
+
kind: "current-changes",
|
|
974
|
+
projection: "workspace",
|
|
975
|
+
baseTree: "b".repeat(40),
|
|
976
|
+
initialReviewTree: "b".repeat(40),
|
|
977
|
+
currentCandidateTree: "b".repeat(40),
|
|
978
|
+
pathsDigest: "a".repeat(64),
|
|
979
|
+
paths: [],
|
|
980
|
+
intendedUntracked: [],
|
|
981
|
+
intendedUntrackedProof: "a".repeat(64),
|
|
982
|
+
initialSnapshotIdentity: "a".repeat(64),
|
|
983
|
+
currentSnapshotIdentity: "a".repeat(64),
|
|
984
|
+
},
|
|
985
|
+
candidates: [],
|
|
986
|
+
raw: { schema: "gentle-ai.review-integration.status/v5" },
|
|
987
|
+
} as unknown as ReviewStatusV3;
|
|
988
|
+
const native = {
|
|
989
|
+
targetStatus: async () => target,
|
|
990
|
+
start: async () => { nativeStarts += 1; throw new Error("native START must not run"); },
|
|
991
|
+
} as unknown as NativeReviewCli;
|
|
992
|
+
const candidateViews = {
|
|
993
|
+
createOrReuse: () => { throw new CandidateViewError("candidate view owner preparation failed", "candidate-owner-preparation-failed"); },
|
|
994
|
+
} as unknown as CandidateViewRegistry;
|
|
995
|
+
const result = await __testing.executeReviewControllerOperation(
|
|
996
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
|
|
997
|
+
process.cwd(),
|
|
998
|
+
native,
|
|
999
|
+
undefined,
|
|
1000
|
+
candidateViews,
|
|
1001
|
+
);
|
|
1002
|
+
assert.equal(nativeStarts, 0);
|
|
1003
|
+
assert.equal(result.outcome, "native-operation-failed");
|
|
1004
|
+
assert.equal(result.mutation_outcome, "none");
|
|
1005
|
+
assert.deepEqual(result.diagnostics, {
|
|
1006
|
+
code: "candidate-owner-preparation-failed",
|
|
1007
|
+
message: "candidate view rejected before native START",
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
let materializationStatusCalls = 0;
|
|
1011
|
+
const rawMaterializationFailure = await __testing.executeReviewControllerOperation(
|
|
1012
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
|
|
1013
|
+
process.cwd(),
|
|
1014
|
+
{
|
|
1015
|
+
targetStatus: async () => { materializationStatusCalls += 1; return target; },
|
|
1016
|
+
start: async () => { nativeStarts += 1; throw new Error("native START must not run"); },
|
|
1017
|
+
} as unknown as NativeReviewCli,
|
|
1018
|
+
undefined,
|
|
1019
|
+
{ createOrReuse: () => { throw new Error("candidate materialization failed"); } } as unknown as CandidateViewRegistry,
|
|
1020
|
+
);
|
|
1021
|
+
assert.equal(nativeStarts, 0);
|
|
1022
|
+
assert.equal(materializationStatusCalls, 1, "a pre-START materialization failure never triggers reconciliation STATUS");
|
|
1023
|
+
assert.equal(rawMaterializationFailure.outcome, "native-operation-failed");
|
|
1024
|
+
assert.equal(rawMaterializationFailure.mutation_outcome, "none");
|
|
1025
|
+
assert.equal(rawMaterializationFailure.next_action, "resolve-native-operation-failure");
|
|
1026
|
+
|
|
1027
|
+
let statusCalls = 0;
|
|
1028
|
+
const afterNative = await __testing.executeReviewControllerOperation(
|
|
1029
|
+
{ operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
|
|
1030
|
+
process.cwd(),
|
|
1031
|
+
{
|
|
1032
|
+
targetStatus: async () => { statusCalls += 1; return target; },
|
|
1033
|
+
start: async () => {
|
|
1034
|
+
nativeStarts += 1;
|
|
1035
|
+
throw new CandidateViewError("post-native candidate verification failed", "candidate-view-timeout", {
|
|
1036
|
+
phase: "candidate-view",
|
|
1037
|
+
category: "timeout",
|
|
1038
|
+
git_subcommand: "worktree",
|
|
1039
|
+
timeout_ms: 10_000,
|
|
1040
|
+
max_buffer_bytes: 64 * 1024 * 1024,
|
|
1041
|
+
message: "candidate-view Git command worktree timed out after 10000ms; inspect the candidate state before any new START",
|
|
1042
|
+
});
|
|
1043
|
+
},
|
|
1044
|
+
} as unknown as NativeReviewCli,
|
|
1045
|
+
undefined,
|
|
1046
|
+
null,
|
|
1047
|
+
);
|
|
1048
|
+
assert.equal(nativeStarts, 1);
|
|
1049
|
+
assert.equal(statusCalls, 2, "a post-native diagnostic must reconcile STATUS");
|
|
1050
|
+
assert.equal(afterNative.mutation_outcome, "unknown");
|
|
1051
|
+
assert.deepEqual(afterNative.diagnostics, {
|
|
1052
|
+
code: "candidate-view-timeout",
|
|
1053
|
+
message: "post-native candidate verification failed",
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
379
1057
|
test("agent model discovery prioritizes SDD and Judgment Day agents", (t) => {
|
|
380
1058
|
const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-agents-"));
|
|
381
1059
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
@@ -435,8 +1113,8 @@ test("model panel render does not auto-apply the Gentle theme and sanitizes agen
|
|
|
435
1113
|
const rendered = lines.join("\n");
|
|
436
1114
|
const plain = stripAnsi(rendered);
|
|
437
1115
|
|
|
438
|
-
assert.doesNotMatch(rendered, /\x1b\[38;2;71;85;105m/);
|
|
439
|
-
assert.doesNotMatch(rendered, /\x1b\[38;2;125;211;252m/);
|
|
1116
|
+
assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;71;85;105m/);
|
|
1117
|
+
assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;125;211;252m/);
|
|
440
1118
|
assert.match(plain, /Assign Models and Effort to Agents/);
|
|
441
1119
|
assert.match(plain, /safe-agent\s+model=inherit, effort=inherit/);
|
|
442
1120
|
assert.doesNotMatch(plain, /\[31m/);
|
|
@@ -990,3 +1668,281 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
|
|
|
990
1668
|
rmSync(cwd, { recursive: true, force: true });
|
|
991
1669
|
}
|
|
992
1670
|
});
|
|
1671
|
+
test("registered Gentle Review capture tools name the lens they run", () => {
|
|
1672
|
+
const tools = registeredGentleTools();
|
|
1673
|
+
const binding = (lens: string) => JSON.stringify({ name: "reviewer_result", captureOperation: "review.capture-result", arguments: [], artifactSubject: { lens } });
|
|
1674
|
+
const single = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: binding("review-risk") }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
|
|
1675
|
+
assert.equal(cardTitle(renderComponent(single)), "🌹︎ Gentle AI · running · review capture · risk");
|
|
1676
|
+
const bare = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: "{not json" }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
|
|
1677
|
+
assert.equal(cardTitle(renderComponent(bare)), "🌹︎ Gentle AI · running · review capture");
|
|
1678
|
+
const group = tools.get("gentle_review_capture_group")!.renderCall(
|
|
1679
|
+
{ lineageId: "l", collectBindings: [binding("review-risk"), binding("review-resilience"), binding("review-readability"), binding("review-reliability")] },
|
|
1680
|
+
lifecycleTheme,
|
|
1681
|
+
lifecycleContext({ executionStarted: true }),
|
|
1682
|
+
);
|
|
1683
|
+
assert.equal(cardTitle(renderComponent(group)), "🌹︎ Gentle AI · running · review capture group · risk · resilience · readability · reliability");
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
test("bash tool_call confirms a late guarded npm publish and denies on non-approval", async () => {
|
|
1687
|
+
type ToolCallHandler = (
|
|
1688
|
+
event: { toolName: string; input: unknown },
|
|
1689
|
+
ctx: ExtensionContext,
|
|
1690
|
+
) => Promise<ToolCallEventResult | undefined>;
|
|
1691
|
+
|
|
1692
|
+
const confirmArgs: Array<[string, string]> = [];
|
|
1693
|
+
const handlers = new Map<string, ToolCallHandler>();
|
|
1694
|
+
const pi = {
|
|
1695
|
+
on(name: string, handler: ToolCallHandler) {
|
|
1696
|
+
handlers.set(name, handler);
|
|
1697
|
+
},
|
|
1698
|
+
events: { emit() {} },
|
|
1699
|
+
registerCommand() {},
|
|
1700
|
+
registerTool() {},
|
|
1701
|
+
} as unknown as ExtensionAPI;
|
|
1702
|
+
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1703
|
+
const toolCall = handlers.get("tool_call");
|
|
1704
|
+
assert.equal(typeof toolCall, "function");
|
|
1705
|
+
|
|
1706
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-guard-confirm-"));
|
|
1707
|
+
const ctx = {
|
|
1708
|
+
cwd: process.cwd(),
|
|
1709
|
+
hasUI: true,
|
|
1710
|
+
ui: {
|
|
1711
|
+
confirm: async (title: string, message: string) => {
|
|
1712
|
+
confirmArgs.push([title, message]);
|
|
1713
|
+
return false;
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
} as ExtensionContext;
|
|
1717
|
+
|
|
1718
|
+
const prefix = "noise ".repeat(80);
|
|
1719
|
+
const command = `${prefix}npm publish --tag beta`;
|
|
1720
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
1721
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
1722
|
+
try {
|
|
1723
|
+
const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
|
|
1724
|
+
assert.deepEqual(result, {
|
|
1725
|
+
block: true,
|
|
1726
|
+
reason:
|
|
1727
|
+
"Gentle AI safety policy blocked the command because it was not confirmed.",
|
|
1728
|
+
});
|
|
1729
|
+
} finally {
|
|
1730
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
1731
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
1732
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
assert.equal(confirmArgs.length, 1, "guard asks exactly one confirmation before denying");
|
|
1736
|
+
const [title, preview] = confirmArgs[0];
|
|
1737
|
+
assert.equal(title, "Allow guarded npm publish?");
|
|
1738
|
+
assert.match(preview, /npm publish --tag beta/);
|
|
1739
|
+
assert.ok(preview.startsWith("…"), "preview elides leading context near a late match");
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
test("bash tool_call confirms every compound action and centers a long git -C push", async () => {
|
|
1743
|
+
type ToolCallHandler = (
|
|
1744
|
+
event: { toolName: string; input: unknown },
|
|
1745
|
+
ctx: ExtensionContext,
|
|
1746
|
+
) => Promise<ToolCallEventResult | undefined>;
|
|
1747
|
+
const confirmArgs: Array<[string, string]> = [];
|
|
1748
|
+
const handlers = new Map<string, ToolCallHandler>();
|
|
1749
|
+
const pi = {
|
|
1750
|
+
on(name: string, handler: ToolCallHandler) { handlers.set(name, handler); },
|
|
1751
|
+
events: { emit() {} },
|
|
1752
|
+
registerCommand() {},
|
|
1753
|
+
registerTool() {},
|
|
1754
|
+
} as unknown as ExtensionAPI;
|
|
1755
|
+
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1756
|
+
const toolCall = handlers.get("tool_call");
|
|
1757
|
+
assert.equal(typeof toolCall, "function");
|
|
1758
|
+
|
|
1759
|
+
const configHome = mkdtempSync(join(tmpdir(), "gentle-pi-guard-compound-"));
|
|
1760
|
+
const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
1761
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
1762
|
+
try {
|
|
1763
|
+
const command = `git -C /${"very-long-path/".repeat(30)} push origin main && npm publish --tag beta`;
|
|
1764
|
+
const result = await toolCall!({ toolName: "bash", input: { command } }, {
|
|
1765
|
+
cwd: process.cwd(),
|
|
1766
|
+
hasUI: true,
|
|
1767
|
+
ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) },
|
|
1768
|
+
} as ExtensionContext);
|
|
1769
|
+
assert.deepEqual(result, {
|
|
1770
|
+
block: true,
|
|
1771
|
+
reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
|
|
1772
|
+
});
|
|
1773
|
+
} finally {
|
|
1774
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
1775
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
1776
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
assert.equal(confirmArgs.length, 1);
|
|
1780
|
+
const [title, preview] = confirmArgs[0];
|
|
1781
|
+
assert.equal(title, "Allow guarded actions: git push; npm publish?");
|
|
1782
|
+
assert.match(preview, /push origin main && npm publish --tag beta/);
|
|
1783
|
+
assert.ok(preview.startsWith("…"));
|
|
1784
|
+
});
|
|
1785
|
+
// /gentle:profiles reopens its panel after every action, so a test that applies
|
|
1786
|
+
// once must confirm on the first visit and close on the next, or the panel and
|
|
1787
|
+
// the action loop feed each other forever.
|
|
1788
|
+
function applyOnce(
|
|
1789
|
+
fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
|
|
1790
|
+
): void {
|
|
1791
|
+
let visits = 0;
|
|
1792
|
+
fixture.onInput((panel) => {
|
|
1793
|
+
visits += 1;
|
|
1794
|
+
panel.handleInput(visits === 1 ? "\r" : "\x1b");
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function profilesStoreFixture(t: test.TestContext) {
|
|
1799
|
+
const fixture = routingConsumerFixture(t, ["worker"]);
|
|
1800
|
+
const storePath = join(fixture.configHome, "profiles.json");
|
|
1801
|
+
const settingsPath = join(fixture.agentHome, "settings.json");
|
|
1802
|
+
const writeStore = (profiles: Record<string, unknown>, active?: string) => {
|
|
1803
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1804
|
+
const store: Record<string, unknown> = { kind: PROFILES_KIND, version: PROFILES_VERSION, profiles };
|
|
1805
|
+
if (active !== undefined) store.active = active;
|
|
1806
|
+
writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`);
|
|
1807
|
+
};
|
|
1808
|
+
const writeSettings = (extra: Record<string, unknown> = {}) => {
|
|
1809
|
+
const settings = {
|
|
1810
|
+
packages: ["npm:pi-mcp-adapter"],
|
|
1811
|
+
theme: "Gentleman-Cute",
|
|
1812
|
+
defaultProvider: "nan",
|
|
1813
|
+
defaultModel: "deepseek-v4-flash",
|
|
1814
|
+
defaultThinkingLevel: "high",
|
|
1815
|
+
...extra,
|
|
1816
|
+
};
|
|
1817
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1818
|
+
return settings;
|
|
1819
|
+
};
|
|
1820
|
+
return { fixture, storePath, settingsPath, writeStore, writeSettings };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
test("applying a profile persists its orchestrator and never leaks the key into agent routing", async (t) => {
|
|
1824
|
+
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
1825
|
+
const before = writeSettings();
|
|
1826
|
+
writeStore({
|
|
1827
|
+
team: {
|
|
1828
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
1829
|
+
worker: { model: "openai/alpha" },
|
|
1830
|
+
},
|
|
1831
|
+
});
|
|
1832
|
+
applyOnce(fixture);
|
|
1833
|
+
await fixture.run("gentle:profiles");
|
|
1834
|
+
|
|
1835
|
+
const after = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1836
|
+
assert.equal(after.defaultProvider, "nan");
|
|
1837
|
+
assert.equal(after.defaultModel, "glm5.3");
|
|
1838
|
+
assert.equal(after.defaultThinkingLevel, "max");
|
|
1839
|
+
assert.deepEqual(after.packages, before.packages, "unrelated settings keys survive");
|
|
1840
|
+
assert.equal(after.theme, before.theme, "unrelated settings keys survive");
|
|
1841
|
+
|
|
1842
|
+
// The reserved key is routing, not an agent: it must never reach the
|
|
1843
|
+
// subagent profile store or the agent frontmatter.
|
|
1844
|
+
const projectProfiles = JSON.parse(readFileSync(join(fixture.root, ".pi", "subagents.json"), "utf8"));
|
|
1845
|
+
assert.equal("orchestrator" in projectProfiles.model_profiles, false);
|
|
1846
|
+
assert.equal(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"));
|
|
1847
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
|
|
1848
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
1849
|
+
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · max/);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
test("applying a profile without an orchestrator entry leaves settings.json untouched", async (t) => {
|
|
1853
|
+
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
1854
|
+
writeSettings();
|
|
1855
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
1856
|
+
applyOnce(fixture);
|
|
1857
|
+
await fixture.run("gentle:profiles");
|
|
1858
|
+
|
|
1859
|
+
const after = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
1860
|
+
assert.equal(after.defaultProvider, "nan");
|
|
1861
|
+
assert.equal(after.defaultModel, "deepseek-v4-flash");
|
|
1862
|
+
assert.equal(after.defaultThinkingLevel, "high");
|
|
1863
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
1864
|
+
assert.doesNotMatch(applied, /Orchestrator set to/);
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
test("a profile store entry with only the orchestrator key counts zero roles", async (t) => {
|
|
1868
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
1869
|
+
writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "high" } } }, "team");
|
|
1870
|
+
applyOnce(fixture);
|
|
1871
|
+
await fixture.run("gentle:profiles");
|
|
1872
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
1873
|
+
assert.match(applied, /0 agents updated/);
|
|
1874
|
+
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
test("the profiles panel fills the terminal, lists routing per agent, and scrolls", async (t) => {
|
|
1878
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
1879
|
+
writeStore({
|
|
1880
|
+
team: {
|
|
1881
|
+
orchestrator: { model: "nan/glm5.3", thinking: "high" },
|
|
1882
|
+
worker: { model: "openai/alpha", thinking: "high" },
|
|
1883
|
+
"sdd-design": { model: "nan/glm5.3", thinking: "high" },
|
|
1884
|
+
},
|
|
1885
|
+
}, "team");
|
|
1886
|
+
let rendered: string | undefined;
|
|
1887
|
+
let panel: { render(width: number): string[] } | undefined;
|
|
1888
|
+
fixture.onInput((visited) => {
|
|
1889
|
+
panel = visited;
|
|
1890
|
+
rendered = renderComponent(visited);
|
|
1891
|
+
visited.handleInput("\x1b");
|
|
1892
|
+
});
|
|
1893
|
+
await fixture.run("gentle:profiles");
|
|
1894
|
+
|
|
1895
|
+
assert.ok(rendered);
|
|
1896
|
+
const lines = rendered.split("\n");
|
|
1897
|
+
// The frame spans the terminal height the fake TUI reports.
|
|
1898
|
+
assert.equal(lines.length, 24, `expected 24 rows, got ${lines.length}`);
|
|
1899
|
+
assert.match(lines[0], /^╭/);
|
|
1900
|
+
assert.match(lines.at(-1)!, /^╰/);
|
|
1901
|
+
const text = rendered;
|
|
1902
|
+
// Routing is listed one agent per line, aligned in columns, never collapsed
|
|
1903
|
+
// into "N agents → model: a, b, …" summaries.
|
|
1904
|
+
assert.match(text, /Profile routing/);
|
|
1905
|
+
assert.match(text, /Current routing \(models\.json\)/);
|
|
1906
|
+
assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
|
|
1907
|
+
assert.match(text, /worker\s+openai\/alpha\s+high/);
|
|
1908
|
+
assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
|
|
1909
|
+
assert.doesNotMatch(text, /agents? → /);
|
|
1910
|
+
|
|
1911
|
+
// A taller terminal renders a taller frame with the same content.
|
|
1912
|
+
fixture.tui.terminal.rows = 40;
|
|
1913
|
+
const taller = renderComponent(panel);
|
|
1914
|
+
assert.equal(taller.split("\n").length, 40);
|
|
1915
|
+
|
|
1916
|
+
// A short terminal clamps instead of crashing, and keeps both borders.
|
|
1917
|
+
fixture.tui.terminal.rows = 9;
|
|
1918
|
+
const short = stripAnsi(panel.render(120).map((line) => line.replace(/[ \t]+$/g, "")).join("\n"));
|
|
1919
|
+
const shortLines = short.split("\n");
|
|
1920
|
+
assert.equal(shortLines.length, 9);
|
|
1921
|
+
assert.match(shortLines[0], /^╭/);
|
|
1922
|
+
assert.match(shortLines.at(-1)!, /^╰/);
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
test("j and k scroll the detail pane one line at a time, like the agents view", async (t) => {
|
|
1926
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
1927
|
+
const routing: Record<string, { model: string; thinking: string }> = {};
|
|
1928
|
+
for (let index = 1; index <= 25; index += 1) {
|
|
1929
|
+
routing[`agent-${String(index).padStart(2, "0")}`] = { model: "openai/alpha", thinking: "high" };
|
|
1930
|
+
}
|
|
1931
|
+
writeStore({ team: routing }, "team");
|
|
1932
|
+
let panel: { handleInput(data: string): void; render(width: number): string[] } | undefined;
|
|
1933
|
+
fixture.onInput((visited) => {
|
|
1934
|
+
panel = visited;
|
|
1935
|
+
visited.handleInput("\x1b");
|
|
1936
|
+
});
|
|
1937
|
+
await fixture.run("gentle:profiles");
|
|
1938
|
+
assert.ok(panel, "the panel must open");
|
|
1939
|
+
const body = () => panel!.render(120).slice(1, -2).map((line) => line.replace(/[ \t]+$/, "")).join("\n");
|
|
1940
|
+
const firstAgentRow = (text: string) => text.split("\n").findIndex((line) => line.includes("agent-01"));
|
|
1941
|
+
const before = body();
|
|
1942
|
+
assert.ok(firstAgentRow(before) >= 0, "the first routing row must be visible before scrolling");
|
|
1943
|
+
panel!.handleInput("j");
|
|
1944
|
+
const afterJ = body();
|
|
1945
|
+
assert.notEqual(firstAgentRow(afterJ), firstAgentRow(before), "j must scroll the detail down by one line");
|
|
1946
|
+
panel!.handleInput("k");
|
|
1947
|
+
assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
|
|
1948
|
+
});
|