gentle-pi 4.1.0 → 4.3.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 +352 -128
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +188 -35
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +40 -8
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +20 -12
- package/docs/install-wizard.md +17 -10
- package/docs/permission-modes.md +51 -6
- package/docs/readme-reference.md +155 -31
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +21 -7
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +146 -28
- package/extensions/gentle-ai.ts +380 -68
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +53 -43
- package/extensions/gentle-todo.ts +62 -18
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +215 -28
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +62 -8
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +146 -12
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +40 -16
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +39 -8
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/vim-editor-adapter.ts +22 -4
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +5 -6
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +61 -7
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +144 -10
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/pi-update.mjs +223 -0
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +208 -16
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +35 -7
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +443 -8
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +180 -17
- package/tests/gentle-shell-bundled.test.ts +235 -38
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +97 -13
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +113 -14
- package/tests/gentle-todo.test.ts +258 -27
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +13 -5
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +28 -24
- package/tests/pi-update-launcher.test.ts +106 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +131 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +37 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +24 -1
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
@@ -25,11 +25,14 @@ import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TAS
|
|
|
25
25
|
import { NativePointerScope } from "../lib/native-pointer-region.ts";
|
|
26
26
|
import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
|
|
27
27
|
import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
|
|
28
|
+
import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
|
|
28
29
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
29
30
|
import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
30
|
-
import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
|
|
31
|
+
import { AgentRunner, REQUESTED_TOOLS_ENV, type TaskRequest } from "../lib/agents-runner.ts";
|
|
31
32
|
import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
|
|
32
33
|
import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
34
|
+
import { taskHeader } from "../lib/agents-view.ts";
|
|
35
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
33
36
|
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
34
37
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
35
38
|
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
@@ -92,6 +95,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
|
|
|
92
95
|
const activeSessionTeardowns = new Set<() => Promise<void>>();
|
|
93
96
|
const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
|
|
94
97
|
afterEach(stopActiveSessions);
|
|
98
|
+
// Every fixture session is "s1": the inherited-profile freeze is process state
|
|
99
|
+
// keyed by session id, so each test starts from an unfrozen session.
|
|
100
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
95
101
|
// subagent_run's default mode now reads the background-subagents policy
|
|
96
102
|
// in-process (gentle-pi#background-subagents-default-mode), which falls
|
|
97
103
|
// back to the real ~/.pi/gentle-ai/background-subagents.json when
|
|
@@ -353,7 +359,9 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
353
359
|
pi: { command: "pi", args: [] },
|
|
354
360
|
home,
|
|
355
361
|
resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
|
|
356
|
-
|
|
362
|
+
// The launch reads the global active profile from the config home; keep it
|
|
363
|
+
// on the scratch directory instead of the developer's own profiles.json.
|
|
364
|
+
env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME },
|
|
357
365
|
sessionTransport: inertSessionTransport,
|
|
358
366
|
},
|
|
359
367
|
};
|
|
@@ -1223,7 +1231,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
|
|
|
1223
1231
|
return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
|
|
1224
1232
|
source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
1225
1233
|
} };
|
|
1226
|
-
const env: NodeJS.ProcessEnv = {};
|
|
1234
|
+
const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
1227
1235
|
const profile = join(root, `metrics-${boundary}`);
|
|
1228
1236
|
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
1229
1237
|
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
@@ -2227,6 +2235,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2227
2235
|
try {
|
|
2228
2236
|
const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
|
|
2229
2237
|
const h = fakePi();
|
|
2238
|
+
// The launch reads the global active profile; keep it on the scratch config home.
|
|
2239
|
+
env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
2230
2240
|
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
|
|
2231
2241
|
const { ctx } = fakeContext();
|
|
2232
2242
|
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
@@ -2258,7 +2268,10 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2258
2268
|
assert.equal(captured[index]?.command, "/fixture/pi");
|
|
2259
2269
|
assert.deepEqual(captured[index]?.args, args);
|
|
2260
2270
|
assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
|
|
2261
|
-
|
|
2271
|
+
const scratch = captured[index]?.options.env.TMPDIR;
|
|
2272
|
+
assert.match(scratch ?? "", /[\\/]ga[A-Za-z0-9]{6}$/, "each child receives its own TMPDIR");
|
|
2273
|
+
const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
2274
|
+
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
|
|
2262
2275
|
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
2263
2276
|
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
2264
2277
|
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
@@ -2387,6 +2400,9 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2387
2400
|
await tick();
|
|
2388
2401
|
assert.deepEqual(spawned, [foreign]);
|
|
2389
2402
|
assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
|
|
2403
|
+
// The parent session froze "no profile" (no pin, no global active), so the
|
|
2404
|
+
// foreign launch keeps the foreign repository's own pin (gentle-shell#1064
|
|
2405
|
+
// 3b-ii). Dropping `foreignRepository` from the launch wiring breaks this.
|
|
2390
2406
|
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
|
|
2391
2407
|
assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2392
2408
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
@@ -2437,6 +2453,34 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2437
2453
|
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2438
2454
|
});
|
|
2439
2455
|
|
|
2456
|
+
test("a frozen session profile routes a foreign repository launch over that repository's pin", async (t) => {
|
|
2457
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-frozen-")));
|
|
2458
|
+
t.after(() => rmSync(fixture, TEST_DIR_REMOVAL));
|
|
2459
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2460
|
+
mkdirSync(template);
|
|
2461
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2462
|
+
const configHome = join(fixture, "config");
|
|
2463
|
+
mkdirSync(configHome);
|
|
2464
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "session", profiles: {
|
|
2465
|
+
session: { explore: { model: "openai/session-model", thinking: "low" } },
|
|
2466
|
+
pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } },
|
|
2467
|
+
} }));
|
|
2468
|
+
mkdirSync(join(foreign, ".git", "gentle-ai"));
|
|
2469
|
+
writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
|
|
2470
|
+
const h = fakePi(), runtime = deps();
|
|
2471
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
|
|
2472
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2473
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2474
|
+
const { ctx } = fakeContext();
|
|
2475
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2476
|
+
await h.fire("session_start", ctx);
|
|
2477
|
+
t.after(async () => { await h.fire("session_shutdown", ctx); await tick(); });
|
|
2478
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "session", "the parent froze the global active profile");
|
|
2479
|
+
await h.tools.get("subagent_run")!.execute("foreign-frozen", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2480
|
+
await tick();
|
|
2481
|
+
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/session-model:low", "the session profile wins over the foreign pin");
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2440
2484
|
test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
|
|
2441
2485
|
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-changes-")));
|
|
2442
2486
|
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), sibling = join(fixture, "sibling"), template = join(fixture, "template");
|
|
@@ -2826,7 +2870,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2826
2870
|
Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
|
|
2827
2871
|
ctx.sessionManager.getCwd = () => project;
|
|
2828
2872
|
ctx.sessionManager.getEntries = () => h.entries as never;
|
|
2829
|
-
|
|
2873
|
+
// The repository declaration exists before the session starts: the session
|
|
2874
|
+
// freezes it at session_start (gentle-shell#1064 slice 3b-ii).
|
|
2830
2875
|
if (scenario === "profile-model" || scenario === "profile-valid") {
|
|
2831
2876
|
mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
|
|
2832
2877
|
writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
|
|
@@ -2834,6 +2879,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2834
2879
|
mkdirSync(config, { recursive: true });
|
|
2835
2880
|
writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } }));
|
|
2836
2881
|
}
|
|
2882
|
+
await h.fire("session_start", ctx);
|
|
2837
2883
|
let calls = 0;
|
|
2838
2884
|
const abort = new AbortController();
|
|
2839
2885
|
const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
|
|
@@ -3095,8 +3141,8 @@ function pinFixture(name: string) {
|
|
|
3095
3141
|
declarationPath,
|
|
3096
3142
|
writePin: (profile: string) => writePinText(localPinPath, profile),
|
|
3097
3143
|
writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
|
|
3098
|
-
writeStore: (profiles: Record<string, unknown
|
|
3099
|
-
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
|
|
3144
|
+
writeStore: (profiles: Record<string, unknown>, active?: string) => {
|
|
3145
|
+
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`);
|
|
3100
3146
|
},
|
|
3101
3147
|
};
|
|
3102
3148
|
}
|
|
@@ -3222,6 +3268,282 @@ test("a queued launch keeps the session routing frozen across a rebind", async t
|
|
|
3222
3268
|
}
|
|
3223
3269
|
});
|
|
3224
3270
|
|
|
3271
|
+
// gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter
|
|
3272
|
+
// binding freezes p → P → G at session_start; later default changes only show
|
|
3273
|
+
// a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live
|
|
3274
|
+
// resolution.
|
|
3275
|
+
async function launchSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
|
|
3276
|
+
const harness = deps();
|
|
3277
|
+
harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
|
|
3278
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
|
|
3279
|
+
const { pi, tools, fire } = fakePi();
|
|
3280
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3281
|
+
const { ctx, dialogs } = fakeContext();
|
|
3282
|
+
await fire("session_start", ctx);
|
|
3283
|
+
let count = 0;
|
|
3284
|
+
return {
|
|
3285
|
+
dialogs,
|
|
3286
|
+
driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")),
|
|
3287
|
+
launch: async () => {
|
|
3288
|
+
await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx);
|
|
3289
|
+
await tick();
|
|
3290
|
+
const args = harness.spawned[count];
|
|
3291
|
+
// Settle the child so the next launch is not held by the concurrency cap.
|
|
3292
|
+
const child = harness.children[count++]!;
|
|
3293
|
+
child.emit({ type: "agent_end", messages: [] });
|
|
3294
|
+
child.emit({ type: "agent_settled" });
|
|
3295
|
+
child.exit(0);
|
|
3296
|
+
await tick();
|
|
3297
|
+
return args[args.indexOf("--model") + 1];
|
|
3298
|
+
},
|
|
3299
|
+
shutdown: async () => {
|
|
3300
|
+
await fire("session_shutdown", ctx);
|
|
3301
|
+
await tick();
|
|
3302
|
+
},
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
const DRIFT_PROFILES = {
|
|
3307
|
+
frontier: { explore: { model: "openai/alpha", thinking: "minimal" } },
|
|
3308
|
+
local: { explore: { model: "openai/beta" } },
|
|
3309
|
+
};
|
|
3310
|
+
|
|
3311
|
+
test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => {
|
|
3312
|
+
const base = pinFixture("freeze-pin");
|
|
3313
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3314
|
+
base.writePin("frontier");
|
|
3315
|
+
const session = await launchSession(base);
|
|
3316
|
+
t.after(session.shutdown);
|
|
3317
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch");
|
|
3318
|
+
base.writePin("local");
|
|
3319
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3320
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3321
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3322
|
+
'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3323
|
+
], "one notice for one change, across two launches");
|
|
3324
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3325
|
+
base.writeDeclaration("local");
|
|
3326
|
+
rmSync(base.localPinPath);
|
|
3327
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3328
|
+
assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change");
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
test("an unpinned session routes through the global active profile and freezes it", async t => {
|
|
3332
|
+
const base = pinFixture("freeze-global");
|
|
3333
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3334
|
+
const session = await launchSession(base);
|
|
3335
|
+
t.after(session.shutdown);
|
|
3336
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3337
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing");
|
|
3338
|
+
assert.equal(session.driftNotices().length, 1);
|
|
3339
|
+
assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/);
|
|
3340
|
+
});
|
|
3341
|
+
|
|
3342
|
+
test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => {
|
|
3343
|
+
const base = pinFixture("freeze-none");
|
|
3344
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3345
|
+
const session = await launchSession(base);
|
|
3346
|
+
t.after(session.shutdown);
|
|
3347
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing");
|
|
3348
|
+
base.writePin("frontier");
|
|
3349
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
|
|
3350
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3351
|
+
'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3352
|
+
]);
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => {
|
|
3356
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3357
|
+
const base = pinFixture("freeze-explicit");
|
|
3358
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3359
|
+
base.writePin("frontier");
|
|
3360
|
+
const session = await launchSession(base);
|
|
3361
|
+
t.after(session.shutdown);
|
|
3362
|
+
bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } });
|
|
3363
|
+
base.writePin("local");
|
|
3364
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3365
|
+
rmSync(base.localPinPath);
|
|
3366
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3367
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3368
|
+
});
|
|
3369
|
+
|
|
3370
|
+
test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => {
|
|
3371
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3372
|
+
const base = pinFixture("follow");
|
|
3373
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3374
|
+
base.writePin("frontier");
|
|
3375
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3376
|
+
t.after(session.shutdown);
|
|
3377
|
+
assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes");
|
|
3378
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3379
|
+
base.writePin("local");
|
|
3380
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3381
|
+
bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } });
|
|
3382
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following");
|
|
3383
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3384
|
+
});
|
|
3385
|
+
|
|
3386
|
+
// gentle-shell#2180: the launch seam records which profile layer supplied the
|
|
3387
|
+
// model, next to the model itself, so every task card shows the winning layer
|
|
3388
|
+
// and stale disk edits below a frozen session become visible.
|
|
3389
|
+
interface LaunchCapture {
|
|
3390
|
+
request: TaskRequest;
|
|
3391
|
+
record: TaskRecord;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function captureLaunches(t: TestContext): LaunchCapture[] {
|
|
3395
|
+
const captures: LaunchCapture[] = [];
|
|
3396
|
+
const original = AgentRunner.prototype.run;
|
|
3397
|
+
t.mock.method(AgentRunner.prototype, "run", function (this: AgentRunner, request: TaskRequest) {
|
|
3398
|
+
const record = original.call(this, request);
|
|
3399
|
+
captures.push({ request, record });
|
|
3400
|
+
return record;
|
|
3401
|
+
});
|
|
3402
|
+
return captures;
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
// A non-Git variant of launchSession: the resolver returns nothing, so the
|
|
3406
|
+
// declaration is the only pin layer (as non-Git writer admission reads it).
|
|
3407
|
+
async function launchNonGitSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
|
|
3408
|
+
const harness = deps();
|
|
3409
|
+
harness.deps.resolveWorktree = () => undefined;
|
|
3410
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
|
|
3411
|
+
const { pi, tools, fire } = fakePi();
|
|
3412
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3413
|
+
const { ctx } = fakeContext();
|
|
3414
|
+
// Outside Git the declaration is read at the session's own cwd, so the
|
|
3415
|
+
// fixture binds a dedicated session directory instead of leaking the
|
|
3416
|
+
// declaration into the shared fixture cwd other tests launch from.
|
|
3417
|
+
const sessionCwd = join(base.root, "session");
|
|
3418
|
+
mkdirSync(join(sessionCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
3419
|
+
copyFileSync(base.declarationPath, join(sessionCwd, ".pi", "gentle-ai", "profile.json"));
|
|
3420
|
+
ctx.sessionManager.getCwd = () => sessionCwd;
|
|
3421
|
+
await fire("session_start", ctx);
|
|
3422
|
+
return {
|
|
3423
|
+
launch: async () => {
|
|
3424
|
+
await tools.get("subagent_run")!.execute("source", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
3425
|
+
await tick();
|
|
3426
|
+
},
|
|
3427
|
+
shutdown: async () => {
|
|
3428
|
+
await fire("session_shutdown", ctx);
|
|
3429
|
+
await tick();
|
|
3430
|
+
},
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
test("a bound session launch records the binding as the model source on the task record", async t => {
|
|
3435
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3436
|
+
const captures = captureLaunches(t);
|
|
3437
|
+
const base = pinFixture("source-binding");
|
|
3438
|
+
base.writeStore({ pinned: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3439
|
+
base.writePin("pinned");
|
|
3440
|
+
bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
|
|
3441
|
+
const session = await launchSession(base);
|
|
3442
|
+
t.after(session.shutdown);
|
|
3443
|
+
await session.launch();
|
|
3444
|
+
assert.equal(captures[0]!.request.modelSource, "binding", "the explicit Enter binding wins per the chain precedence");
|
|
3445
|
+
assert.equal(captures[0]!.record.modelSource, "binding", "the record copies the request's layer token");
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
test("a frozen global-active launch records frozen:global as the model source", async t => {
|
|
3449
|
+
const captures = captureLaunches(t);
|
|
3450
|
+
const base = pinFixture("source-freeze-global");
|
|
3451
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3452
|
+
const session = await launchSession(base);
|
|
3453
|
+
t.after(session.shutdown);
|
|
3454
|
+
await session.launch();
|
|
3455
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:global");
|
|
3456
|
+
});
|
|
3457
|
+
|
|
3458
|
+
test("a frozen local-pin launch records frozen:local as the model source", async t => {
|
|
3459
|
+
const captures = captureLaunches(t);
|
|
3460
|
+
const base = pinFixture("source-freeze-local");
|
|
3461
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3462
|
+
base.writePin("frontier");
|
|
3463
|
+
const session = await launchSession(base);
|
|
3464
|
+
t.after(session.shutdown);
|
|
3465
|
+
await session.launch();
|
|
3466
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:local");
|
|
3467
|
+
});
|
|
3468
|
+
|
|
3469
|
+
test("a session frozen on a non-Git declaration records frozen:repo as the model source", async t => {
|
|
3470
|
+
const captures = captureLaunches(t);
|
|
3471
|
+
const base = pinFixture("source-freeze-declaration");
|
|
3472
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3473
|
+
base.writeDeclaration("declared");
|
|
3474
|
+
const session = await launchNonGitSession(base);
|
|
3475
|
+
t.after(session.shutdown);
|
|
3476
|
+
await session.launch();
|
|
3477
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:repo");
|
|
3478
|
+
});
|
|
3479
|
+
|
|
3480
|
+
test("an agent outside every profile records its frontmatter as the model source", async t => {
|
|
3481
|
+
const captures = captureLaunches(t);
|
|
3482
|
+
const base = pinFixture("source-definition");
|
|
3483
|
+
// The pinned profile replaces routing wholesale and does not name explore,
|
|
3484
|
+
// so explore's model comes from its own frontmatter.
|
|
3485
|
+
base.writeStore({ helper: { model: "openai/beta" } });
|
|
3486
|
+
base.writePin("helper");
|
|
3487
|
+
const session = await launchSession(base);
|
|
3488
|
+
t.after(session.shutdown);
|
|
3489
|
+
await session.launch();
|
|
3490
|
+
assert.equal(captures[0]!.request.modelSource, "definition");
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3493
|
+
test("a follow launch through the globally active profile records profile as the model source", async t => {
|
|
3494
|
+
const captures = captureLaunches(t);
|
|
3495
|
+
const base = pinFixture("source-follow-global");
|
|
3496
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3497
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3498
|
+
t.after(session.shutdown);
|
|
3499
|
+
await session.launch();
|
|
3500
|
+
assert.equal(captures[0]!.request.modelSource, "profile");
|
|
3501
|
+
});
|
|
3502
|
+
|
|
3503
|
+
test("a follow launch through the local pin records pin:local as the model source", async t => {
|
|
3504
|
+
const captures = captureLaunches(t);
|
|
3505
|
+
const base = pinFixture("source-follow-pin");
|
|
3506
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3507
|
+
base.writePin("frontier");
|
|
3508
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3509
|
+
t.after(session.shutdown);
|
|
3510
|
+
await session.launch();
|
|
3511
|
+
assert.equal(captures[0]!.request.modelSource, "pin:local");
|
|
3512
|
+
});
|
|
3513
|
+
|
|
3514
|
+
test("a follow launch through a committed repository declaration records pin:repo as the model source", async t => {
|
|
3515
|
+
const captures = captureLaunches(t);
|
|
3516
|
+
const base = pinFixture("source-follow-declaration");
|
|
3517
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3518
|
+
base.writeDeclaration("declared");
|
|
3519
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3520
|
+
t.after(session.shutdown);
|
|
3521
|
+
await session.launch();
|
|
3522
|
+
assert.equal(captures[0]!.request.modelSource, "pin:repo");
|
|
3523
|
+
});
|
|
3524
|
+
|
|
3525
|
+
test("a follow launch through a non-Git repository declaration records declaration as the model source", async t => {
|
|
3526
|
+
const captures = captureLaunches(t);
|
|
3527
|
+
const base = pinFixture("source-follow-nongit");
|
|
3528
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3529
|
+
base.writeDeclaration("declared");
|
|
3530
|
+
const session = await launchNonGitSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3531
|
+
t.after(session.shutdown);
|
|
3532
|
+
await session.launch();
|
|
3533
|
+
assert.equal(captures[0]!.request.modelSource, "declaration");
|
|
3534
|
+
});
|
|
3535
|
+
|
|
3536
|
+
test("the agents task card shows the winning layer next to the model, and renders unchanged without one (#2180)", () => {
|
|
3537
|
+
const record = {
|
|
3538
|
+
id: "task", agent: "explore", mode: "task", prompt: "Map the repo", label: "Map", cwd: "/repo", parentSessionId: "s1",
|
|
3539
|
+
status: TASK_STATUS.RUNNING, createdAt: 60_000, startedAt: 60_000, endedAt: null, model: "nan/glm5.3",
|
|
3540
|
+
thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", lastActivityAt: 60_000,
|
|
3541
|
+
turns: 0, toolCalls: 0, tokens: 0, cost: 0,
|
|
3542
|
+
} as TaskRecord;
|
|
3543
|
+
assert.equal(taskHeader({ ...record, modelSource: "binding" }, 61_000), "explore · running · nan/glm5.3 · binding · 1s");
|
|
3544
|
+
assert.equal(taskHeader(record, 61_000), "explore · running · nan/glm5.3 · 1s", "absent modelSource renders exactly as today");
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3225
3547
|
test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
|
|
3226
3548
|
assert.equal(agentsEnabled({}), true);
|
|
3227
3549
|
assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
|
|
@@ -4406,6 +4728,39 @@ test("registered orchestrator_list joins peer metadata without child launches or
|
|
|
4406
4728
|
} finally { publisher.dispose(); }
|
|
4407
4729
|
});
|
|
4408
4730
|
|
|
4731
|
+
test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => {
|
|
4732
|
+
const h = fakePi();
|
|
4733
|
+
const runtime = deps();
|
|
4734
|
+
const profile = realpathSync(mkdtempSync(join(root, "discovery-legacy-")));
|
|
4735
|
+
const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
|
|
4736
|
+
// A peer running pre-publication code heartbeats its schema-1 header only.
|
|
4737
|
+
const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] });
|
|
4738
|
+
try {
|
|
4739
|
+
let ready = false;
|
|
4740
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
4741
|
+
runtime.deps.agentHome = profile;
|
|
4742
|
+
runtime.deps.sessionTransport = {
|
|
4743
|
+
createRegistry: async () => registry,
|
|
4744
|
+
createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
|
|
4745
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
|
|
4746
|
+
};
|
|
4747
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4748
|
+
const { ctx } = fakeContext();
|
|
4749
|
+
await h.fire("session_start", ctx);
|
|
4750
|
+
await eventually(() => ready, "discovery transport ready");
|
|
4751
|
+
const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
|
|
4752
|
+
assert.equal(result.content[0].text, "Advertised sessions (reachability is unknown):\n"
|
|
4753
|
+
+ "- peer · gentle-shell · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)"
|
|
4754
|
+
+ " · repository: not published · metadata: legacy\n recorded catalog: unknown");
|
|
4755
|
+
// Consultation stays fail-closed: a legacy label is display-only.
|
|
4756
|
+
const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx);
|
|
4757
|
+
assert.equal(JSON.parse(consult.content[0].text).status, "unavailable");
|
|
4758
|
+
assert.equal(runtime.spawned.length, 0);
|
|
4759
|
+
assert.equal(h.sent.length, 0);
|
|
4760
|
+
await h.fire("session_shutdown", ctx);
|
|
4761
|
+
} finally { publisher.dispose(); }
|
|
4762
|
+
});
|
|
4763
|
+
|
|
4409
4764
|
test("registered reasoning and revocation use live SDK host and published source only", async () => {
|
|
4410
4765
|
const h = fakePi(), runtime = deps();
|
|
4411
4766
|
const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
|
|
@@ -6344,3 +6699,83 @@ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, th
|
|
|
6344
6699
|
jobs.cleanup();
|
|
6345
6700
|
}
|
|
6346
6701
|
});
|
|
6702
|
+
|
|
6703
|
+
// Harness roadmap T6: todo progress reaches peers without a model turn spent on
|
|
6704
|
+
// orchestrator_session_id, and never replaces the owner's curated fields.
|
|
6705
|
+
test("todo progress publishes merged into the owner's curated state, only for this session", async () => {
|
|
6706
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
6707
|
+
const profile = realpathSync(mkdtempSync(join(root, "todo-progress-")));
|
|
6708
|
+
runtime.deps.agentHome = profile;
|
|
6709
|
+
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress.sock", createdAt: 1 };
|
|
6710
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
6711
|
+
runtime.deps.sessionTransport = {
|
|
6712
|
+
createRegistry: async () => registry,
|
|
6713
|
+
createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
|
|
6714
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
|
|
6715
|
+
};
|
|
6716
|
+
Object.assign(h.pi, { setSessionName() {} });
|
|
6717
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
|
|
6718
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
6719
|
+
await h.fire("session_start", ctx);
|
|
6720
|
+
const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
|
|
6721
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 2, current: "Write parser" });
|
|
6722
|
+
// PR #2290 review: a todo title is private work text; peers see counts only.
|
|
6723
|
+
assert.deepEqual(published(), { progress: "0/2 done" }, "progress publishes before any curated state, without the todo title");
|
|
6724
|
+
assert.doesNotMatch(JSON.stringify(readDiscovery(profile, listPresence(profile).entries[0])), /Write parser/, "the todo title never reaches peers");
|
|
6725
|
+
await h.tools.get("orchestrator_session_id")!.execute("own", { subject: "Ledger", state: { objective: "Ship the ledger", decisions: "inline" } }, undefined, undefined, ctx);
|
|
6726
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 2, current: null });
|
|
6727
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline", progress: "1/2 done" });
|
|
6728
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.aliases?.currentAlias, "Ledger", "the subject survives");
|
|
6729
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "other", done: 2, total: 2, current: null });
|
|
6730
|
+
assert.equal(published()?.progress, "1/2 done", "another session's todo is ignored");
|
|
6731
|
+
// R1 review advisory: a cleared list published "0/0 done" as if it were progress.
|
|
6732
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 0, current: null });
|
|
6733
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline" }, "a cleared list removes the progress line and keeps curated fields");
|
|
6734
|
+
// R1 review advisory: a withdrawn state must stay withdrawn until the owner publishes again.
|
|
6735
|
+
await h.tools.get("orchestrator_session_id")!.execute("withdraw", { state: null }, undefined, undefined, ctx);
|
|
6736
|
+
assert.equal(published(), null);
|
|
6737
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Docs" });
|
|
6738
|
+
assert.equal(published(), null, "automatic progress does not republish a withdrawn state");
|
|
6739
|
+
await h.tools.get("orchestrator_session_id")!.execute("own", { state: { objective: "Ship v2" } }, undefined, undefined, ctx);
|
|
6740
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
6741
|
+
assert.deepEqual(published(), { objective: "Ship v2", progress: "2/3 done" }, "progress resumes once the owner publishes again");
|
|
6742
|
+
});
|
|
6743
|
+
|
|
6744
|
+
// PR #2290 review: an owner publication without `progress` replaced the automatic
|
|
6745
|
+
// progress line, so peers lost it until the next todo write.
|
|
6746
|
+
test("an owner publication without progress keeps the automatic progress; explicit progress and null still win", async () => {
|
|
6747
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
6748
|
+
const profile = realpathSync(mkdtempSync(join(root, "todo-progress-merge-")));
|
|
6749
|
+
runtime.deps.agentHome = profile;
|
|
6750
|
+
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress-merge.sock", createdAt: 1 };
|
|
6751
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
6752
|
+
runtime.deps.sessionTransport = {
|
|
6753
|
+
createRegistry: async () => registry,
|
|
6754
|
+
createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
|
|
6755
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
|
|
6756
|
+
};
|
|
6757
|
+
Object.assign(h.pi, { setSessionName() {} });
|
|
6758
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
|
|
6759
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
6760
|
+
await h.fire("session_start", ctx);
|
|
6761
|
+
const published = () => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
|
|
6762
|
+
const own = (state: unknown) => h.tools.get("orchestrator_session_id")!.execute("own", { state }, undefined, undefined, ctx);
|
|
6763
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Parser" });
|
|
6764
|
+
await own({ objective: "Ship the ledger" });
|
|
6765
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", progress: "1/3 done" }, "omitted progress keeps the automatic line");
|
|
6766
|
+
await own({ objective: "Ship the ledger", progress: "Reviewing the parser" });
|
|
6767
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", progress: "Reviewing the parser" }, "explicit owner progress wins");
|
|
6768
|
+
await own({ objective: "Ship the ledger" });
|
|
6769
|
+
assert.deepEqual(published(), { objective: "Ship the ledger" }, "owner progress is replaced like any curated field");
|
|
6770
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
6771
|
+
await own(null);
|
|
6772
|
+
assert.equal(published(), null, "null withdraws everything");
|
|
6773
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 3, total: 3, current: null });
|
|
6774
|
+
assert.equal(published(), null, "the withdrawal holds against automatic progress");
|
|
6775
|
+
await own({ objective: "Ship v2" });
|
|
6776
|
+
assert.deepEqual(published(), { objective: "Ship v2" }, "a withdrawn progress line is not merged back");
|
|
6777
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 4, current: null });
|
|
6778
|
+
const full = "x".repeat(2048);
|
|
6779
|
+
await own({ objective: full });
|
|
6780
|
+
assert.deepEqual(published(), { objective: full }, "a merge over the state budget publishes the owner's state as given");
|
|
6781
|
+
});
|
|
@@ -98,7 +98,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
|
|
|
98
98
|
method: "go-sumdb-source-build",
|
|
99
99
|
package: "github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai",
|
|
100
100
|
module: "github.com/gentleman-programming/gentle-ai/v4",
|
|
101
|
-
tag: "v4.
|
|
101
|
+
tag: "v4.2.0",
|
|
102
102
|
architecture: process.arch === "x64" ? "x64" : "arm64",
|
|
103
103
|
binarySha256: createHash("sha256").update(binary).digest("hex"),
|
|
104
104
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -164,6 +165,66 @@ test("child session_start re-arms YOLO and the review sidebar for its own sessio
|
|
|
164
165
|
assert.ok(sidebarSessions().every((id) => id === "session-child-next"), JSON.stringify(sidebarSessions()));
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
// Harness roadmap T5: the delivered-test lint runs in the shared tool_call
|
|
169
|
+
// handler, so it holds for the parent's inline writes and for every child.
|
|
170
|
+
for (const child of [false, true]) {
|
|
171
|
+
test(`${child ? "child" : "parent"} tool_call blocks a test that reads a literal commit, and allows the same write elsewhere`, async (t) => {
|
|
172
|
+
const { cwd } = isolate(t);
|
|
173
|
+
const h = harness(child, cwd);
|
|
174
|
+
await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
|
|
175
|
+
const content = 'execSync("git show 3f9c2e1:src/ledger.ts");\n';
|
|
176
|
+
const call = (path: string) => h.handlers.get("tool_call")!({ toolName: "write", toolCallId: "call-1", input: { path, content } }, h.ctx) as Promise<{ block?: boolean; reason?: string } | undefined>;
|
|
177
|
+
const blocked = await call("tests/ledger.test.ts");
|
|
178
|
+
assert.equal(blocked?.block, true);
|
|
179
|
+
assert.match(blocked?.reason ?? "", /git history/);
|
|
180
|
+
assert.equal((await call("scripts/check.ts"))?.block, undefined, "a non-test file is not linted");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Harness roadmap T4: only the parent owns delivery, so only its commits get
|
|
185
|
+
// the slice line; the bash output and structured content are kept.
|
|
186
|
+
for (const child of [false, true]) {
|
|
187
|
+
test(`${child ? "child" : "parent"} bash tool_result ${child ? "never adds" : "appends"} the commit slice line`, async (t) => {
|
|
188
|
+
const { cwd } = isolate(t);
|
|
189
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8" });
|
|
190
|
+
git("init", "-q", "-b", "main");
|
|
191
|
+
writeFileSync(join(cwd, "README.md"), "base\n");
|
|
192
|
+
git("add", "README.md");
|
|
193
|
+
git("commit", "-q", "-m", "base");
|
|
194
|
+
git("switch", "-q", "-c", "feat/x");
|
|
195
|
+
writeFileSync(join(cwd, "a.ts"), "one\ntwo\n");
|
|
196
|
+
git("add", "a.ts");
|
|
197
|
+
git("commit", "-q", "-m", "feat: a");
|
|
198
|
+
const h = harness(child, cwd);
|
|
199
|
+
await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
|
|
200
|
+
const event = { toolName: "bash", toolCallId: "call-1", isError: false, input: { command: "git commit -m 'feat: a'" }, content: [{ type: "text", text: "[feat/x 1234567] feat: a" }], structuredContent: { exitCode: 0 } };
|
|
201
|
+
const result = await h.handlers.get("tool_result")!(event, h.ctx) as { content?: Array<{ text?: string }>; structuredContent?: unknown } | undefined;
|
|
202
|
+
if (child) return assert.equal(result, undefined);
|
|
203
|
+
assert.equal(result?.content?.[0]?.text, "[feat/x 1234567] feat: a", "the command output is kept first");
|
|
204
|
+
assert.match(result?.content?.[1]?.text ?? "", /Commit slice: 2 authored changed lines in 1 commit on feat\/x since main/);
|
|
205
|
+
assert.deepEqual(result?.structuredContent, { exitCode: 0 });
|
|
206
|
+
const failed = await h.handlers.get("tool_result")!({ ...event, isError: true }, h.ctx);
|
|
207
|
+
assert.equal(failed, undefined, "a failed commit reports nothing");
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Native review tools hung when called from a codemode script (a nested call with
|
|
212
|
+
// parentToolCallId), while direct calls returned at once. The runtime refuses the
|
|
213
|
+
// nested form so a review never stalls the session.
|
|
214
|
+
test("review tools called from inside another tool are refused; direct calls and other nested tools are not", async (t) => {
|
|
215
|
+
const { cwd } = isolate(t);
|
|
216
|
+
const h = harness(false, cwd);
|
|
217
|
+
await h.handlers.get("session_start")!({ reason: "startup" }, h.ctx);
|
|
218
|
+
const call = (toolName: string, parentToolCallId?: string) => h.handlers.get("tool_call")!({ toolName, toolCallId: "call-1", input: { operation: "status" }, ...(parentToolCallId ? { parentToolCallId } : {}) }, h.ctx) as Promise<{ block?: boolean; reason?: string } | undefined>;
|
|
219
|
+
for (const tool of ["gentle_review", "gentle_review_capture", "gentle_review_capture_group", "gentle_review_scope"]) {
|
|
220
|
+
const nested = await call(tool, "codemode-1");
|
|
221
|
+
assert.equal(nested?.block, true, tool);
|
|
222
|
+
assert.match(nested?.reason ?? "", /call it directly, never from codemode or another tool/, tool);
|
|
223
|
+
}
|
|
224
|
+
assert.equal((await call("gentle_review_scope"))?.block, undefined, "a direct review call is not refused by this guard");
|
|
225
|
+
assert.equal((await call("read", "codemode-1"))?.block, undefined, "other nested tools still run");
|
|
226
|
+
});
|
|
227
|
+
|
|
167
228
|
async function writeToolResult(child: boolean, t: TestContext) {
|
|
168
229
|
const { cwd } = isolate(t);
|
|
169
230
|
mkdirSync(join(cwd, "src"), { recursive: true });
|