gentle-pi 4.0.0 → 4.2.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 +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- 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 +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- 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 +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import { gzipSync } from "node:zlib";
|
|
9
9
|
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
10
11
|
import type {
|
|
11
12
|
ExtensionAPI,
|
|
12
13
|
ExtensionContext,
|
|
@@ -14,9 +15,10 @@ import type {
|
|
|
14
15
|
ToolCallEventResult,
|
|
15
16
|
} from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
17
|
-
import { PROFILES_KIND, PROFILES_VERSION } from "../lib/agent-profiles.ts";
|
|
18
|
-
import
|
|
19
|
-
type
|
|
18
|
+
import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
19
|
+
import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
20
|
+
import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
|
|
21
|
+
type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
|
|
20
22
|
import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
|
|
21
23
|
import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
22
24
|
import { CandidateViewError, type CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
@@ -24,9 +26,35 @@ import { installPackageAssets } from "../lib/agent-assets.ts";
|
|
|
24
26
|
import type { ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
25
27
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
26
28
|
import { cardBody, cardTitle, cardTone } from "./gentle-card-text.ts";
|
|
29
|
+
import { readDelegationDetail } from "./support/orchestrator-modules.ts";
|
|
27
30
|
|
|
28
31
|
initTheme("dark");
|
|
29
32
|
|
|
33
|
+
// Observe the actual UI boundary, not a guessed number of identity-read microtasks.
|
|
34
|
+
function confirmationObserver() {
|
|
35
|
+
let count = 0;
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
return {
|
|
38
|
+
observe() { count++; for (const listener of listeners) listener(); },
|
|
39
|
+
waitFor(expected: number): Promise<void> {
|
|
40
|
+
if (count >= expected) return Promise.resolve();
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const deadline = setTimeout(() => {
|
|
43
|
+
listeners.delete(check);
|
|
44
|
+
reject(new Error(`Confirmation deadline: expected ${expected}, observed ${count}`));
|
|
45
|
+
}, 1000);
|
|
46
|
+
const check = () => {
|
|
47
|
+
if (count < expected) return;
|
|
48
|
+
clearTimeout(deadline);
|
|
49
|
+
listeners.delete(check);
|
|
50
|
+
resolve();
|
|
51
|
+
};
|
|
52
|
+
listeners.add(check);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
function writeMarkdown(path: string, content: string): void {
|
|
31
59
|
mkdirSync(dirname(path), { recursive: true });
|
|
32
60
|
writeFileSync(path, content);
|
|
@@ -293,7 +321,7 @@ test("registered Gentle Review tools preserve result envelopes and preview usefu
|
|
|
293
321
|
{ manifest: encoded, sha256, cursor: 0 },
|
|
294
322
|
undefined,
|
|
295
323
|
undefined,
|
|
296
|
-
{ cwd: process.cwd() } as ExtensionContext,
|
|
324
|
+
{ cwd: process.cwd() } as unknown as ExtensionContext,
|
|
297
325
|
);
|
|
298
326
|
const visibleEnvelope = JSON.parse(result.content[0].text);
|
|
299
327
|
assert.deepEqual(visibleEnvelope, {
|
|
@@ -414,14 +442,23 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
414
442
|
{ provider: "openai", id: "beta" },
|
|
415
443
|
{ provider: "nan", id: "glm5.3" },
|
|
416
444
|
];
|
|
445
|
+
let onConfirm: (title: string, message: string) => Promise<boolean> = async () => true;
|
|
446
|
+
const confirmCalls: Array<[string, string]> = [];
|
|
417
447
|
const ctx = {
|
|
418
448
|
cwd: root,
|
|
419
449
|
hasUI: true,
|
|
450
|
+
// A real Pi session always has an id; the profiles panel resolves the
|
|
451
|
+
// session binding through it, so the fixture models one stable session.
|
|
452
|
+
sessionManager: { getSessionId: () => "session-panel" },
|
|
420
453
|
modelRegistry: {
|
|
421
454
|
getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
|
|
422
455
|
find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
|
|
423
456
|
},
|
|
424
457
|
ui: {
|
|
458
|
+
confirm: async (title: string, message: string) => {
|
|
459
|
+
confirmCalls.push([title, message]);
|
|
460
|
+
return onConfirm(title, message);
|
|
461
|
+
},
|
|
425
462
|
notify(message: string, severity: string) { notifications.push({ message, severity }); },
|
|
426
463
|
input: async (title: string, placeholder?: string) => {
|
|
427
464
|
inputPrompts.push({ title, placeholder });
|
|
@@ -453,6 +490,8 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
453
490
|
},
|
|
454
491
|
refuseSetModel() { setModelResult = false; },
|
|
455
492
|
rejectThinkingLevel() { thinkingRejects = true; },
|
|
493
|
+
confirmCalls,
|
|
494
|
+
onConfirm(handler: (title: string, message: string) => Promise<boolean>) { onConfirm = handler; },
|
|
456
495
|
onPanel(action: typeof onPanel) { onPanel = action; },
|
|
457
496
|
onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
|
|
458
497
|
answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
|
|
@@ -715,7 +754,7 @@ test("session startup reports invalid project routing without mutating the profi
|
|
|
715
754
|
registerCommand() {},
|
|
716
755
|
registerTool() {},
|
|
717
756
|
} as unknown as ExtensionAPI;
|
|
718
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
757
|
+
createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } })(pi);
|
|
719
758
|
const sessionStart = handlers.get("session_start");
|
|
720
759
|
assert.equal(typeof sessionStart, "function");
|
|
721
760
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
@@ -875,7 +914,7 @@ test("runtime guidance keeps review policy out of the static orchestrator and te
|
|
|
875
914
|
}
|
|
876
915
|
|
|
877
916
|
const orchestrator = readFileSync("assets/orchestrator.md", "utf8")
|
|
878
|
-
+
|
|
917
|
+
+ readDelegationDetail();
|
|
879
918
|
assert.match(orchestrator, /injects the mirrored provider-bundle review execution contract/);
|
|
880
919
|
assert.match(orchestrator, /this package invents no lifecycle instructions/);
|
|
881
920
|
for (const lifecycleMarker of ["review-risk", "review-reliability", "review-resilience", "review-readability", "Authority-First Terminal Procedure", "reconcile-terminal-mirrors"]) {
|
|
@@ -969,7 +1008,10 @@ test("ordinary START adopts the committed-range selectors the provider just offe
|
|
|
969
1008
|
targetStatus: async () => offeredCommittedRangeStatus(baseCommit, baseTree, candidateTree),
|
|
970
1009
|
start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
|
|
971
1010
|
} as unknown as NativeReviewCli;
|
|
972
|
-
const
|
|
1011
|
+
const parameters = validateToolArguments(registeredGentleTools().get("gentle_review"), {
|
|
1012
|
+
type: "toolCall", id: "object-start", name: "gentle_review", arguments: { operation: "start", input: { mode: "ordinary" } },
|
|
1013
|
+
});
|
|
1014
|
+
const result = await __testing.executeReviewControllerOperation(parameters, cwd, native);
|
|
973
1015
|
assert.equal(result.operation, "start");
|
|
974
1016
|
assert.equal(starts.length, 1);
|
|
975
1017
|
assert.equal(starts[0]?.baseRef, baseCommit);
|
|
@@ -1321,7 +1363,7 @@ test("delivery commands bypass RDD under every mode outcome while command safety
|
|
|
1321
1363
|
cwd: process.cwd(),
|
|
1322
1364
|
hasUI: true,
|
|
1323
1365
|
ui: { confirm: async () => true },
|
|
1324
|
-
} as ExtensionContext;
|
|
1366
|
+
} as unknown as ExtensionContext;
|
|
1325
1367
|
|
|
1326
1368
|
for (const command of commands) {
|
|
1327
1369
|
const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
|
|
@@ -1353,6 +1395,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1353
1395
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1354
1396
|
const emitted: EmittedEvent[] = [];
|
|
1355
1397
|
const sequence: string[] = [];
|
|
1398
|
+
const confirmation = confirmationObserver();
|
|
1356
1399
|
let confirm!: () => Promise<boolean>;
|
|
1357
1400
|
const pi = {
|
|
1358
1401
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1363,7 +1406,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1363
1406
|
sequence.push(
|
|
1364
1407
|
channel === "herdr:blocked"
|
|
1365
1408
|
? `herdr:${"active" in data && data.active ? "active" : "inactive"}`
|
|
1366
|
-
: `event:${data.state}`,
|
|
1409
|
+
: `event:${"state" in data ? data.state : "unknown"}`,
|
|
1367
1410
|
);
|
|
1368
1411
|
emitted.push({ channel, data } as EmittedEvent);
|
|
1369
1412
|
},
|
|
@@ -1382,10 +1425,11 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1382
1425
|
ui: {
|
|
1383
1426
|
confirm: async () => {
|
|
1384
1427
|
sequence.push("confirm");
|
|
1428
|
+
confirmation.observe();
|
|
1385
1429
|
return confirm();
|
|
1386
1430
|
},
|
|
1387
1431
|
},
|
|
1388
|
-
} as ExtensionContext;
|
|
1432
|
+
} as unknown as ExtensionContext;
|
|
1389
1433
|
let resolveConfirmation!: (approved: boolean) => void;
|
|
1390
1434
|
confirm = () => new Promise<boolean>((resolve) => { resolveConfirmation = resolve; });
|
|
1391
1435
|
const denied = toolCall!({
|
|
@@ -1393,7 +1437,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1393
1437
|
input: { command: "git rebase main --secret-command-content" },
|
|
1394
1438
|
}, ctx);
|
|
1395
1439
|
|
|
1396
|
-
await
|
|
1440
|
+
await confirmation.waitFor(1);
|
|
1397
1441
|
assert.equal(emitted[0].channel, "pi-permission-system:permission-request");
|
|
1398
1442
|
assert.equal(emitted[0].data.state, "waiting");
|
|
1399
1443
|
assert.deepEqual(emitted[1], {
|
|
@@ -1497,6 +1541,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1497
1541
|
const createHarness = () => {
|
|
1498
1542
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1499
1543
|
const emitted: EmittedEvent[] = [];
|
|
1544
|
+
const confirmation = confirmationObserver();
|
|
1500
1545
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1501
1546
|
const pi = {
|
|
1502
1547
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1507,7 +1552,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1507
1552
|
registerTool() {},
|
|
1508
1553
|
} as unknown as ExtensionAPI;
|
|
1509
1554
|
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1510
|
-
return { handlers, emitted, confirmations };
|
|
1555
|
+
return { handlers, emitted, confirmations, confirmation };
|
|
1511
1556
|
};
|
|
1512
1557
|
const first = createHarness();
|
|
1513
1558
|
const second = createHarness();
|
|
@@ -1517,12 +1562,15 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1517
1562
|
cwd,
|
|
1518
1563
|
hasUI: true,
|
|
1519
1564
|
ui: {
|
|
1520
|
-
confirm: async () => new Promise<boolean>((resolve) => {
|
|
1565
|
+
confirm: async () => new Promise<boolean>((resolve) => {
|
|
1566
|
+
confirmations.push(resolve);
|
|
1567
|
+
(confirmations === first.confirmations ? first : second).confirmation.observe();
|
|
1568
|
+
}),
|
|
1521
1569
|
},
|
|
1522
|
-
} as ExtensionContext);
|
|
1570
|
+
} as unknown as ExtensionContext);
|
|
1523
1571
|
const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations));
|
|
1524
1572
|
const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations));
|
|
1525
|
-
await
|
|
1573
|
+
await first.confirmation.waitFor(2);
|
|
1526
1574
|
assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
|
|
1527
1575
|
{ channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
|
|
1528
1576
|
{ channel: "herdr:blocked", state: undefined, active: true },
|
|
@@ -1545,7 +1593,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1545
1593
|
]);
|
|
1546
1594
|
|
|
1547
1595
|
const independentRequest = second.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --independent-command" } }, context(second.confirmations));
|
|
1548
|
-
await
|
|
1596
|
+
await second.confirmation.waitFor(1);
|
|
1549
1597
|
assert.equal(second.emitted.filter(({ channel }) => channel === "herdr:blocked").length, 1);
|
|
1550
1598
|
assert.equal(second.emitted.find(({ channel }) => channel === "herdr:blocked")?.data.active, true);
|
|
1551
1599
|
assert.equal(second.confirmations.length, 1);
|
|
@@ -1652,6 +1700,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
|
|
|
1652
1700
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1653
1701
|
const eventHandlers = new Map<string, (data: unknown) => void>();
|
|
1654
1702
|
const herdrEvents: HerdrBlockedEvent[] = [];
|
|
1703
|
+
const confirmation = confirmationObserver();
|
|
1655
1704
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1656
1705
|
const pi = {
|
|
1657
1706
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1676,10 +1725,10 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
|
|
|
1676
1725
|
cwd: process.cwd(),
|
|
1677
1726
|
hasUI: true,
|
|
1678
1727
|
ui: {
|
|
1679
|
-
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
|
|
1728
|
+
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
|
|
1680
1729
|
},
|
|
1681
|
-
} as ExtensionContext;
|
|
1682
|
-
return { confirmations, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! };
|
|
1730
|
+
} as unknown as ExtensionContext;
|
|
1731
|
+
return { confirmations, confirmation, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! };
|
|
1683
1732
|
};
|
|
1684
1733
|
|
|
1685
1734
|
const guardedFirst = createHarness();
|
|
@@ -1687,7 +1736,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
|
|
|
1687
1736
|
{ toolName: "bash", input: { command: "git rebase main" } },
|
|
1688
1737
|
guardedFirst.context,
|
|
1689
1738
|
);
|
|
1690
|
-
await
|
|
1739
|
+
await guardedFirst.confirmation.waitFor(1);
|
|
1691
1740
|
guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
|
|
1692
1741
|
guardedFirst.confirmations[0]!(true);
|
|
1693
1742
|
assert.equal(await guardedRequest, undefined);
|
|
@@ -1708,7 +1757,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
|
|
|
1708
1757
|
const request = overlap.toolCall(
|
|
1709
1758
|
{ toolName: "bash", input: { command: "git rebase main" } }, overlap.context,
|
|
1710
1759
|
);
|
|
1711
|
-
await
|
|
1760
|
+
await overlap.confirmation.waitFor(1);
|
|
1712
1761
|
for (const channel of channels.filter((channel) => channel !== lastChannel)) {
|
|
1713
1762
|
overlap.pi.events.emit(channel, { active: false });
|
|
1714
1763
|
overlap.pi.events.emit(channel, { active: false });
|
|
@@ -1728,7 +1777,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so
|
|
|
1728
1777
|
{ toolName: "bash", input: { command: "git rebase main" } },
|
|
1729
1778
|
questionnaireFirst.context,
|
|
1730
1779
|
);
|
|
1731
|
-
await
|
|
1780
|
+
await questionnaireFirst.confirmation.waitFor(1);
|
|
1732
1781
|
questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
|
|
1733
1782
|
questionnaireFirst.confirmations[0]!(false);
|
|
1734
1783
|
await questionnaireRequest;
|
|
@@ -1748,6 +1797,7 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1748
1797
|
const eventHandlers = new Map<string, (data: unknown) => void>();
|
|
1749
1798
|
const herdrEvents: HerdrBlockedEvent[] = [];
|
|
1750
1799
|
const choiceEvents: Array<{ active: boolean }> = [];
|
|
1800
|
+
const confirmation = confirmationObserver();
|
|
1751
1801
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1752
1802
|
const pi = {
|
|
1753
1803
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1785,11 +1835,11 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1785
1835
|
cwd: process.cwd(),
|
|
1786
1836
|
hasUI: true,
|
|
1787
1837
|
ui: {
|
|
1788
|
-
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
|
|
1838
|
+
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
|
|
1789
1839
|
},
|
|
1790
|
-
} as ExtensionContext,
|
|
1840
|
+
} as unknown as ExtensionContext,
|
|
1791
1841
|
);
|
|
1792
|
-
await
|
|
1842
|
+
await confirmation.waitFor(1);
|
|
1793
1843
|
assert.equal(confirmations.length, 1);
|
|
1794
1844
|
assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
|
|
1795
1845
|
|
|
@@ -1837,12 +1887,12 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
|
|
|
1837
1887
|
cwd,
|
|
1838
1888
|
hasUI: false,
|
|
1839
1889
|
ui: { confirm },
|
|
1840
|
-
} as ExtensionContext), undefined);
|
|
1890
|
+
} as unknown as ExtensionContext), undefined);
|
|
1841
1891
|
assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, {
|
|
1842
1892
|
cwd,
|
|
1843
1893
|
hasUI: false,
|
|
1844
1894
|
ui: { confirm },
|
|
1845
|
-
} as ExtensionContext), {
|
|
1895
|
+
} as unknown as ExtensionContext), {
|
|
1846
1896
|
block: true,
|
|
1847
1897
|
reason: "Gentle AI safety policy requires interactive confirmation before this command.",
|
|
1848
1898
|
});
|
|
@@ -1897,7 +1947,7 @@ test("bash tool_call confirms a late guarded npm publish and denies on non-appro
|
|
|
1897
1947
|
return false;
|
|
1898
1948
|
},
|
|
1899
1949
|
},
|
|
1900
|
-
} as ExtensionContext;
|
|
1950
|
+
} as unknown as ExtensionContext;
|
|
1901
1951
|
|
|
1902
1952
|
const prefix = "noise ".repeat(80);
|
|
1903
1953
|
const command = `${prefix}npm publish --tag beta`;
|
|
@@ -1949,7 +1999,7 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
|
|
|
1949
1999
|
cwd: process.cwd(),
|
|
1950
2000
|
hasUI: true,
|
|
1951
2001
|
ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) },
|
|
1952
|
-
} as ExtensionContext);
|
|
2002
|
+
} as unknown as ExtensionContext);
|
|
1953
2003
|
assert.deepEqual(result, {
|
|
1954
2004
|
block: true,
|
|
1955
2005
|
reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
|
|
@@ -1973,9 +2023,11 @@ function applyOnce(
|
|
|
1973
2023
|
fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
|
|
1974
2024
|
): void {
|
|
1975
2025
|
let visits = 0;
|
|
2026
|
+
// gentle-shell#1064: Enter now binds the parent session, so the legacy global
|
|
2027
|
+
// apply these tests exercise is the explicit `a` key.
|
|
1976
2028
|
fixture.onInput((panel) => {
|
|
1977
2029
|
visits += 1;
|
|
1978
|
-
panel.handleInput(visits === 1 ? "
|
|
2030
|
+
panel.handleInput(visits === 1 ? "a" : "\x1b");
|
|
1979
2031
|
});
|
|
1980
2032
|
}
|
|
1981
2033
|
|
|
@@ -2035,6 +2087,9 @@ test("applying a profile persists its orchestrator and never leaks the key into
|
|
|
2035
2087
|
worker: { model: "openai/alpha" },
|
|
2036
2088
|
},
|
|
2037
2089
|
});
|
|
2090
|
+
// Every global apply now confirms, so this populated apply approves the
|
|
2091
|
+
// dialog explicitly before running.
|
|
2092
|
+
fixture.onConfirm(async () => true);
|
|
2038
2093
|
applyOnce(fixture);
|
|
2039
2094
|
await fixture.run("gentle:profiles");
|
|
2040
2095
|
|
|
@@ -2060,6 +2115,8 @@ test("applying a profile persists its orchestrator and never leaks the key into
|
|
|
2060
2115
|
{ kind: "model", provider: "nan", id: "glm5.3" },
|
|
2061
2116
|
{ kind: "thinking", level: "max" },
|
|
2062
2117
|
], "the live session switches to the profile's orchestrator");
|
|
2118
|
+
assert.equal(fixture.confirmCalls.length, 1, "every global apply asks for confirmation");
|
|
2119
|
+
assert.match(fixture.confirmCalls[0]?.[0] ?? "", /Apply profile/);
|
|
2063
2120
|
});
|
|
2064
2121
|
|
|
2065
2122
|
test("applying a profile whose orchestrator model is unknown to the registry persists the default and says the session did not switch", async (t) => {
|
|
@@ -2125,6 +2182,624 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
|
|
|
2125
2182
|
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
|
|
2126
2183
|
});
|
|
2127
2184
|
|
|
2185
|
+
test("applying an empty profile asks for confirmation and aborts when declined", async (t) => {
|
|
2186
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2187
|
+
writeSettings();
|
|
2188
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2189
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2190
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2191
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2192
|
+
writeStore({
|
|
2193
|
+
empty: {},
|
|
2194
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2195
|
+
}, "team");
|
|
2196
|
+
const before = {
|
|
2197
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2198
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2199
|
+
store: readFileSync(storePath, "utf8"),
|
|
2200
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
fixture.onConfirm(async () => false);
|
|
2204
|
+
|
|
2205
|
+
applyOnce(fixture);
|
|
2206
|
+
await fixture.run("gentle:profiles");
|
|
2207
|
+
|
|
2208
|
+
assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an empty profile");
|
|
2209
|
+
const [title, message] = fixture.confirmCalls[0];
|
|
2210
|
+
assert.equal(title, "Apply empty profile?");
|
|
2211
|
+
assert.match(message, /has no routing entries/);
|
|
2212
|
+
assert.match(message, /replace global routing/);
|
|
2213
|
+
assert.match(message, /empty configuration/);
|
|
2214
|
+
|
|
2215
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
|
|
2216
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
|
|
2217
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
|
|
2218
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
|
|
2219
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2220
|
+
const models = JSON.parse(before.models);
|
|
2221
|
+
assert.deepEqual(models, { worker: { model: "openai/alpha" } }, "global routing must NOT be wiped when declined");
|
|
2222
|
+
|
|
2223
|
+
const store = JSON.parse(before.store);
|
|
2224
|
+
assert.equal(store.active, "team", "active profile must not change when declined");
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
test("applying an empty profile with explicit confirmation replaces global routing with empty config", async (t) => {
|
|
2228
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2229
|
+
writeSettings();
|
|
2230
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2231
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2232
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2233
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2234
|
+
writeStore({
|
|
2235
|
+
empty: {},
|
|
2236
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2237
|
+
}, "team");
|
|
2238
|
+
|
|
2239
|
+
fixture.onConfirm(async () => true);
|
|
2240
|
+
|
|
2241
|
+
applyOnce(fixture);
|
|
2242
|
+
await fixture.run("gentle:profiles");
|
|
2243
|
+
|
|
2244
|
+
assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an empty profile");
|
|
2245
|
+
const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
|
|
2246
|
+
assert.deepEqual(models, {}, "global routing must be replaced with empty config when confirmed");
|
|
2247
|
+
|
|
2248
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2249
|
+
assert.equal(store.active, "empty", "active profile must be set to empty when confirmed");
|
|
2250
|
+
|
|
2251
|
+
const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2252
|
+
assert.deepEqual(subagents, {}, "confirmed empty apply clears the seeded omitted worker route (shared clearing semantics drop the empty key)");
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
test("applying an orchestrator-only profile asks for confirmation and aborts when declined", async (t) => {
|
|
2256
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2257
|
+
writeSettings();
|
|
2258
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2259
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2260
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2261
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2262
|
+
writeStore({
|
|
2263
|
+
orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
|
|
2264
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2265
|
+
}, "team");
|
|
2266
|
+
const before = {
|
|
2267
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2268
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2269
|
+
store: readFileSync(storePath, "utf8"),
|
|
2270
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2271
|
+
};
|
|
2272
|
+
|
|
2273
|
+
fixture.onConfirm(async () => false);
|
|
2274
|
+
|
|
2275
|
+
applyOnce(fixture);
|
|
2276
|
+
await fixture.run("gentle:profiles");
|
|
2277
|
+
|
|
2278
|
+
assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an orchestrator-only profile");
|
|
2279
|
+
const [title, message] = fixture.confirmCalls[0];
|
|
2280
|
+
assert.equal(title, "Apply orchestrator-only profile?");
|
|
2281
|
+
assert.match(message, /has an orchestrator entry but no agent routing entries/);
|
|
2282
|
+
assert.match(message, /clear every materialized agent route/);
|
|
2283
|
+
assert.match(message, /inherit/);
|
|
2284
|
+
assert.match(message, /orchestrator entry/);
|
|
2285
|
+
assert.match(message, /settings\.json/);
|
|
2286
|
+
assert.match(message, /attempt to switch this session/, "the dialog must not promise an unconditional live switch; registry/auth refusal can keep the session model");
|
|
2287
|
+
assert.doesNotMatch(message, /empty configuration/, "orchestrator-only must not promise an entirely empty config");
|
|
2288
|
+
|
|
2289
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
|
|
2290
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
|
|
2291
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
|
|
2292
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
|
|
2293
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2294
|
+
|
|
2295
|
+
const store = JSON.parse(before.store);
|
|
2296
|
+
assert.equal(store.active, "team", "active profile must not change when declined");
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test("applying an orchestrator-only profile with explicit confirmation updates orchestrator and active profile", async (t) => {
|
|
2300
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2301
|
+
writeSettings();
|
|
2302
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2303
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2304
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2305
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2306
|
+
writeStore({
|
|
2307
|
+
orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
|
|
2308
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2309
|
+
}, "team");
|
|
2310
|
+
|
|
2311
|
+
fixture.onConfirm(async () => true);
|
|
2312
|
+
|
|
2313
|
+
applyOnce(fixture);
|
|
2314
|
+
await fixture.run("gentle:profiles");
|
|
2315
|
+
|
|
2316
|
+
assert.equal(fixture.confirmCalls.length, 1, "confirm dialog must be displayed when applying an orchestrator-only profile");
|
|
2317
|
+
const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
|
|
2318
|
+
assert.deepEqual(models, { orchestrator: { model: "nan/glm5.3", thinking: "high" } }, "global routing must hold only orchestrator");
|
|
2319
|
+
|
|
2320
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2321
|
+
assert.equal(store.active, "orchOnly", "active profile must be set to orchOnly when confirmed");
|
|
2322
|
+
|
|
2323
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
2324
|
+
assert.equal(settings.defaultProvider, "nan");
|
|
2325
|
+
assert.equal(settings.defaultModel, "glm5.3");
|
|
2326
|
+
assert.equal(settings.defaultThinkingLevel, "high");
|
|
2327
|
+
|
|
2328
|
+
const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2329
|
+
assert.deepEqual(subagents, {}, "confirmed orchestrator-only apply clears the seeded omitted worker route (shared clearing semantics drop the empty key)");
|
|
2330
|
+
assert.equal("orchestrator" in subagents, false, "the orchestrator key never materializes as an agent route");
|
|
2331
|
+
assert.deepEqual(fixture.liveSwitches, [
|
|
2332
|
+
{ kind: "model", provider: "nan", id: "glm5.3" },
|
|
2333
|
+
{ kind: "thinking", level: "high" },
|
|
2334
|
+
], "the live session switches to the profile's orchestrator");
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
test("applying a populated profile asks for confirmation naming the diff and aborts when declined", async (t) => {
|
|
2338
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2339
|
+
writeSettings();
|
|
2340
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2341
|
+
// Current global routing: "worker" will be replaced, "helper" will be
|
|
2342
|
+
// cleared back to inherit, and "tracer" only exists in the profile.
|
|
2343
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "high" }, helper: { model: "openai/beta" } }, null, 2)}\n`);
|
|
2344
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2345
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" }, helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
2346
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2347
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2348
|
+
writeStore({
|
|
2349
|
+
team: {
|
|
2350
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2351
|
+
worker: { model: "openai/beta", thinking: "max" },
|
|
2352
|
+
tracer: { model: "openai/alpha" },
|
|
2353
|
+
},
|
|
2354
|
+
});
|
|
2355
|
+
const before = {
|
|
2356
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2357
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2358
|
+
store: readFileSync(storePath, "utf8"),
|
|
2359
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
fixture.onConfirm(async () => false);
|
|
2363
|
+
|
|
2364
|
+
applyOnce(fixture);
|
|
2365
|
+
await fixture.run("gentle:profiles");
|
|
2366
|
+
|
|
2367
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2368
|
+
const [title, message] = fixture.confirmCalls[0];
|
|
2369
|
+
assert.equal(title, 'Apply profile "team"?');
|
|
2370
|
+
assert.match(message, /replace global routing/, "the dialog must name the file whose routing is replaced");
|
|
2371
|
+
assert.match(message, /models\.json/);
|
|
2372
|
+
assert.match(message, /worker: openai\/alpha · high → openai\/beta · max/, "the replaced route must name both the old and the new route");
|
|
2373
|
+
assert.match(message, /helper: openai\/beta → inherit/, "the cleared agent must be named with its return to inherit");
|
|
2374
|
+
assert.match(message, /tracer: openai\/alpha \(added\)/, "the added agent must be named");
|
|
2375
|
+
|
|
2376
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
|
|
2377
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
|
|
2378
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
|
|
2379
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
|
|
2380
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
test("applying a populated profile whose routes already match skips the confirmation and applies", async (t) => {
|
|
2384
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2385
|
+
writeSettings();
|
|
2386
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2387
|
+
// The profile's agent routes are identical to the effective current routing
|
|
2388
|
+
// and the profile carries no orchestrator entry, so applying changes nothing:
|
|
2389
|
+
// the dialog is pure friction and the apply must proceed without it.
|
|
2390
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2391
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
2392
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2393
|
+
|
|
2394
|
+
applyOnce(fixture);
|
|
2395
|
+
await fixture.run("gentle:profiles");
|
|
2396
|
+
|
|
2397
|
+
assert.equal(fixture.confirmCalls.length, 0, "a no-op populated apply must not ask for confirmation");
|
|
2398
|
+
assert.ok(
|
|
2399
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /already matches the current global routing/.test(entry.message)),
|
|
2400
|
+
`the no-op apply is disclosed with an informational notice: ${JSON.stringify(fixture.notifications)}`,
|
|
2401
|
+
);
|
|
2402
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2403
|
+
assert.equal(store.active, "team", "the apply still claims the profile as active");
|
|
2404
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } }, "the routing is unchanged");
|
|
2405
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings.json is untouched");
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
test("applying a populated profile whose routes match and whose orchestrator is already set skips the confirmation", async (t) => {
|
|
2409
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2410
|
+
// writeSettings defaults to nan/deepseek-v4-flash · high; the profile's
|
|
2411
|
+
// orchestrator entry says the same, and the live session still runs on it,
|
|
2412
|
+
// so nothing changes anywhere.
|
|
2413
|
+
writeSettings();
|
|
2414
|
+
fixture.setLiveModel("nan", "deepseek-v4-flash", "high");
|
|
2415
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2416
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2417
|
+
writeStore({ team: { orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" }, worker: { model: "openai/alpha" } } });
|
|
2418
|
+
|
|
2419
|
+
applyOnce(fixture);
|
|
2420
|
+
await fixture.run("gentle:profiles");
|
|
2421
|
+
|
|
2422
|
+
assert.equal(fixture.confirmCalls.length, 0, "an already-active profile must not ask for confirmation");
|
|
2423
|
+
assert.deepEqual(fixture.liveSwitches, [], "the no-op apply performs no live switch: the orchestrator is already there");
|
|
2424
|
+
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
test("applying a populated profile keeps the confirmation when the live session runs a different model", async (t) => {
|
|
2428
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2429
|
+
// settings.json and the profile agree on openai/alpha · high, but the live
|
|
2430
|
+
// session was switched to openai/beta mid-session: applying would move the
|
|
2431
|
+
// live session, so the no-op skip must not fire and the dialog must stay.
|
|
2432
|
+
writeSettings({ defaultProvider: "openai", defaultModel: "alpha", defaultThinkingLevel: "high" });
|
|
2433
|
+
fixture.setLiveModel("openai", "beta", "high");
|
|
2434
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2435
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2436
|
+
writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/alpha" } } });
|
|
2437
|
+
|
|
2438
|
+
fixture.onConfirm(async () => false);
|
|
2439
|
+
|
|
2440
|
+
applyOnce(fixture);
|
|
2441
|
+
await fixture.run("gentle:profiles");
|
|
2442
|
+
|
|
2443
|
+
assert.equal(fixture.confirmCalls.length, 1, "a live-session orchestrator move is a real change and must confirm");
|
|
2444
|
+
assert.deepEqual(fixture.liveSwitches, [], "declining must not switch the live session");
|
|
2445
|
+
assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
test("applying a populated profile keeps the confirmation when settings.json holds an invalid thinking level", async (t) => {
|
|
2449
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2450
|
+
// The stored defaultThinkingLevel is not a valid level: readOrchestratorSettings
|
|
2451
|
+
// drops it from the entry, but applyOrchestratorSettings would delete the key
|
|
2452
|
+
// and rewrite the file, so "unchanged" cannot be proven and the dialog stays.
|
|
2453
|
+
writeSettings({ defaultThinkingLevel: "banana" });
|
|
2454
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2455
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2456
|
+
writeStore({ team: { orchestrator: { model: "nan/deepseek-v4-flash" }, worker: { model: "openai/alpha" } } });
|
|
2457
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2458
|
+
|
|
2459
|
+
fixture.onConfirm(async () => false);
|
|
2460
|
+
|
|
2461
|
+
applyOnce(fixture);
|
|
2462
|
+
await fixture.run("gentle:profiles");
|
|
2463
|
+
|
|
2464
|
+
assert.equal(fixture.confirmCalls.length, 1, "an invalid stored thinking level cannot prove a no-op");
|
|
2465
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "declining preserves the invalid key byte-identically");
|
|
2466
|
+
assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
test("applying a populated profile keeps the confirmation when settings.json is unreadable and the profile has an orchestrator", async (t) => {
|
|
2470
|
+
const { fixture, storePath, writeStore, settingsPath } = profilesStoreFixture(t);
|
|
2471
|
+
writeFileSync(settingsPath, "{ not json\n");
|
|
2472
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2473
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2474
|
+
writeStore({ team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } } });
|
|
2475
|
+
|
|
2476
|
+
fixture.onConfirm(async () => false);
|
|
2477
|
+
|
|
2478
|
+
applyOnce(fixture);
|
|
2479
|
+
await fixture.run("gentle:profiles");
|
|
2480
|
+
|
|
2481
|
+
assert.equal(fixture.confirmCalls.length, 1, "an unreadable settings.json cannot prove an orchestrator no-op");
|
|
2482
|
+
assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
test("applying a populated profile with a thinking-only orchestrator entry skips the confirmation", async (t) => {
|
|
2486
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2487
|
+
writeSettings();
|
|
2488
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2489
|
+
// An orchestrator entry without a model is a no-op for the orchestrator:
|
|
2490
|
+
// applyOrchestratorSettings treats a missing model as "leave settings.json
|
|
2491
|
+
// alone", so the apply moves nothing and must not confirm (issue #1683).
|
|
2492
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2493
|
+
writeStore({ team: { orchestrator: { thinking: "max" }, worker: { model: "openai/alpha" } } });
|
|
2494
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2495
|
+
|
|
2496
|
+
applyOnce(fixture);
|
|
2497
|
+
await fixture.run("gentle:profiles");
|
|
2498
|
+
|
|
2499
|
+
assert.equal(fixture.confirmCalls.length, 0, "a thinking-only orchestrator entry changes nothing and must not confirm");
|
|
2500
|
+
assert.ok(
|
|
2501
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /already matches the current global routing/.test(entry.message)),
|
|
2502
|
+
`the no-op apply is disclosed with an informational notice: ${JSON.stringify(fixture.notifications)}`,
|
|
2503
|
+
);
|
|
2504
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings.json is untouched");
|
|
2505
|
+
assert.deepEqual(fixture.liveSwitches, [], "a thinking-only orchestrator entry never switches the live session");
|
|
2506
|
+
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
test("applying a populated profile with matching routes but a different orchestrator still confirms", async (t) => {
|
|
2510
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2511
|
+
writeSettings();
|
|
2512
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2513
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2514
|
+
writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "max" }, worker: { model: "openai/alpha" } } });
|
|
2515
|
+
|
|
2516
|
+
fixture.onConfirm(async () => false);
|
|
2517
|
+
|
|
2518
|
+
applyOnce(fixture);
|
|
2519
|
+
await fixture.run("gentle:profiles");
|
|
2520
|
+
|
|
2521
|
+
assert.equal(fixture.confirmCalls.length, 1, "an orchestrator change is a real change and must confirm");
|
|
2522
|
+
assert.match(fixture.confirmCalls[0]?.[0] ?? "", /Apply profile/);
|
|
2523
|
+
assert.notEqual(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "declining leaves the store untouched");
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
test("profile replacement with 23 routes changes files only after confirmation", async (t) => {
|
|
2527
|
+
for (const approved of [false, true]) {
|
|
2528
|
+
await t.test(approved ? "confirmed replacement" : "declined replacement", async (t) => {
|
|
2529
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2530
|
+
writeSettings();
|
|
2531
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
2532
|
+
const names = ["worker", "review-refuter", ...Array.from({ length: 21 }, (_, i) => `helper-${i + 1}`)];
|
|
2533
|
+
const profiles = Object.fromEntries(names.map((name) => [name, { model: "openai/beta", effort: "high" }]));
|
|
2534
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2535
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ max_concurrency: 3, user_setting: { keep: true }, model_profiles: profiles })}\n`);
|
|
2536
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/beta", thinking: "high" } })}\n`);
|
|
2537
|
+
const markdown = names.map((name) => join(fixture.root, ".pi", "agents", `${name}.md`));
|
|
2538
|
+
for (const [i, path] of markdown.entries()) writeMarkdown(path, `---\nname: ${names[i]}\ndescription: User agent\nmodel: openai/beta\nthinking: high\n---\nUser instruction for ${names[i]}.\n`);
|
|
2539
|
+
const files = [fixture.globalPath, subagentsPath, storePath, settingsPath, ...markdown];
|
|
2540
|
+
const before = files.map((path) => readFileSync(path, "utf8"));
|
|
2541
|
+
fixture.onConfirm(async () => approved);
|
|
2542
|
+
applyOnce(fixture);
|
|
2543
|
+
await fixture.run("gentle:profiles");
|
|
2544
|
+
assert.equal(fixture.confirmCalls.length, 1);
|
|
2545
|
+
assert.equal(fixture.confirmCalls[0][0], 'Apply profile "team"?');
|
|
2546
|
+
for (const name of names.slice(2)) assert.ok(fixture.confirmCalls[0][1].includes(`${name}: openai/beta · high → inherit`), `confirmation names ${name}`);
|
|
2547
|
+
assert.deepEqual(fixture.liveSwitches, [], "no orchestrator changes were requested");
|
|
2548
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before[3]);
|
|
2549
|
+
if (!approved) {
|
|
2550
|
+
assert.deepEqual(files.map((path) => readFileSync(path, "utf8")), before, "declining preserves all 23 routes and Markdown, stores and settings byte-identically");
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
assert.deepEqual(JSON.parse(readFileSync(subagentsPath, "utf8")), { max_concurrency: 3, user_setting: { keep: true }, model_profiles: { worker: { model: "openai/alpha" }, "review-refuter": profiles["review-refuter"] } });
|
|
2554
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2555
|
+
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team");
|
|
2556
|
+
for (const [i, path] of markdown.entries()) {
|
|
2557
|
+
const stored = readFileSync(path, "utf8");
|
|
2558
|
+
assert.ok(stored.endsWith(`User instruction for ${names[i]}.\n`));
|
|
2559
|
+
if (i === 0) { assert.match(stored, /^model: openai\/alpha$/m); assert.doesNotMatch(stored, /^thinking:/m); }
|
|
2560
|
+
else if (i === 1) assert.equal(stored, before[4 + i], "provider review role is not cleared");
|
|
2561
|
+
else { assert.doesNotMatch(stored, /^model:/m); assert.doesNotMatch(stored, /^thinking:/m); }
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
test("applying a populated profile names materialized-only routes it would clear before asking", async (t) => {
|
|
2568
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2569
|
+
writeSettings();
|
|
2570
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2571
|
+
// The saved global routing matches the profile exactly: "helper" is routed
|
|
2572
|
+
// only where the runtime actually resolves it (agent frontmatter and
|
|
2573
|
+
// subagents.json model profiles), and the apply clears it because the profile
|
|
2574
|
+
// omits it. models.json alone never speaks for "helper".
|
|
2575
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2576
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2577
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2578
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2579
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2580
|
+
writeStore({
|
|
2581
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2582
|
+
});
|
|
2583
|
+
const before = {
|
|
2584
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2585
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2586
|
+
store: readFileSync(storePath, "utf8"),
|
|
2587
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2588
|
+
};
|
|
2589
|
+
|
|
2590
|
+
fixture.onConfirm(async () => false);
|
|
2591
|
+
|
|
2592
|
+
applyOnce(fixture);
|
|
2593
|
+
await fixture.run("gentle:profiles");
|
|
2594
|
+
|
|
2595
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2596
|
+
const [title, message] = fixture.confirmCalls[0];
|
|
2597
|
+
assert.equal(title, 'Apply profile "team"?');
|
|
2598
|
+
assert.match(message, /helper: openai\/beta · high → inherit/, "the materialized-only route the apply clears must be named");
|
|
2599
|
+
assert.doesNotMatch(message, /already match/, "routes matching the saved routing must not hide a cleared materialized-only route");
|
|
2600
|
+
|
|
2601
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
|
|
2602
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
|
|
2603
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
|
|
2604
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
|
|
2605
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
test("applying a populated profile clears the materialized-only route it named when confirmed", async (t) => {
|
|
2609
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2610
|
+
writeSettings();
|
|
2611
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2612
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2613
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2614
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2615
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2616
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2617
|
+
writeStore({
|
|
2618
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
fixture.onConfirm(async () => true);
|
|
2622
|
+
|
|
2623
|
+
applyOnce(fixture);
|
|
2624
|
+
await fixture.run("gentle:profiles");
|
|
2625
|
+
|
|
2626
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2627
|
+
const [, message] = fixture.confirmCalls[0];
|
|
2628
|
+
assert.match(message, /helper: openai\/beta · high → inherit/, "the dialog must name the materialized-only route before it is cleared");
|
|
2629
|
+
const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2630
|
+
assert.equal("helper" in subagents.model_profiles, false, "confirming the apply clears the materialized-only route the dialog named");
|
|
2631
|
+
assert.doesNotMatch(readFileSync(helperPath, "utf8"), /^model:/m, "the materialized-only frontmatter route is dropped");
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
test("applying a populated profile with an orchestrator entry discloses the settings and live-session effects", async (t) => {
|
|
2635
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2636
|
+
writeSettings();
|
|
2637
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2638
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2639
|
+
writeStore({
|
|
2640
|
+
team: {
|
|
2641
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2642
|
+
worker: { model: "openai/beta" },
|
|
2643
|
+
},
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
fixture.onConfirm(async () => false);
|
|
2647
|
+
|
|
2648
|
+
applyOnce(fixture);
|
|
2649
|
+
await fixture.run("gentle:profiles");
|
|
2650
|
+
|
|
2651
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2652
|
+
const [, message] = fixture.confirmCalls[0];
|
|
2653
|
+
assert.match(message, /replace global routing/, "the readable variant must keep naming the routing diff");
|
|
2654
|
+
assert.match(message, /settings\.json/, "the dialog must disclose that the orchestrator entry is set in settings.json");
|
|
2655
|
+
assert.match(message, /attempt to switch this session/, "the dialog must disclose the attempted live-session switch");
|
|
2656
|
+
assert.doesNotMatch(message, /will switch this session/, "the dialog must not promise an unconditional live switch");
|
|
2657
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
test("applying a populated profile with an orchestrator entry discloses the effects when the routing is unreadable", async (t) => {
|
|
2661
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2662
|
+
writeSettings();
|
|
2663
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2664
|
+
// Malformed models.json keeps the unreadable-authority disclosure branch in
|
|
2665
|
+
// charge: the orchestrator effects must be added there, not swap it out.
|
|
2666
|
+
writeFileSync(fixture.globalPath, "{ not json\n");
|
|
2667
|
+
writeStore({
|
|
2668
|
+
team: {
|
|
2669
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2670
|
+
worker: { model: "openai/alpha" },
|
|
2671
|
+
},
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
fixture.onConfirm(async () => false);
|
|
2675
|
+
|
|
2676
|
+
applyOnce(fixture);
|
|
2677
|
+
await fixture.run("gentle:profiles");
|
|
2678
|
+
|
|
2679
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2680
|
+
const [, message] = fixture.confirmCalls[0];
|
|
2681
|
+
assert.match(message, /could not be read/, "the unreadable-authority disclosure must stay authoritative");
|
|
2682
|
+
assert.match(message, /settings\.json/, "the dialog must disclose that the orchestrator entry is set in settings.json");
|
|
2683
|
+
assert.match(message, /attempt to switch this session/, "the dialog must disclose the attempted live-session switch");
|
|
2684
|
+
assert.doesNotMatch(message, /will switch this session/, "the dialog must not promise an unconditional live switch");
|
|
2685
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
test("applying a populated profile with an unreadable routing authority discloses the replace effect instead of added labels", async (t) => {
|
|
2689
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2690
|
+
writeSettings();
|
|
2691
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2692
|
+
// Malformed JSON makes readModelRoutingAuthorityAsync report the global
|
|
2693
|
+
// models.json as invalid: real routes exist on disk but cannot be diffed.
|
|
2694
|
+
writeFileSync(fixture.globalPath, "{ not json\n");
|
|
2695
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2696
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" } } }, null, 2)}\n`);
|
|
2697
|
+
writeStore({
|
|
2698
|
+
team: {
|
|
2699
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2700
|
+
worker: { model: "openai/alpha" },
|
|
2701
|
+
},
|
|
2702
|
+
});
|
|
2703
|
+
const before = {
|
|
2704
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2705
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2706
|
+
store: readFileSync(storePath, "utf8"),
|
|
2707
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2708
|
+
};
|
|
2709
|
+
|
|
2710
|
+
fixture.onConfirm(async () => false);
|
|
2711
|
+
|
|
2712
|
+
applyOnce(fixture);
|
|
2713
|
+
await fixture.run("gentle:profiles");
|
|
2714
|
+
|
|
2715
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2716
|
+
const [title, message] = fixture.confirmCalls[0];
|
|
2717
|
+
assert.equal(title, 'Apply profile "team"?');
|
|
2718
|
+
assert.match(message, /could not be read/, "the dialog must disclose that the current global routing is unreadable");
|
|
2719
|
+
assert.match(message, /replace global routing/, "the dialog must name the global replacement");
|
|
2720
|
+
assert.match(message, /inherit/, "the dialog must disclose the clear-back-to-inherit effect");
|
|
2721
|
+
assert.doesNotMatch(message, /\(added\)/, "unread current routes must not be presented as merely added");
|
|
2722
|
+
|
|
2723
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models, "declined apply must preserve models.json byte-identically");
|
|
2724
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents, "declined apply must preserve subagents.json byte-identically");
|
|
2725
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "declined apply must preserve the profiles store byte-identically");
|
|
2726
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "declined apply must preserve settings.json byte-identically");
|
|
2727
|
+
assert.deepEqual(fixture.liveSwitches, [], "declined apply must not switch the live session");
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
test("applying a populated profile with an unreadable routing authority still applies when confirmed", async (t) => {
|
|
2731
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2732
|
+
writeSettings();
|
|
2733
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2734
|
+
writeFileSync(fixture.globalPath, "{ not json\n");
|
|
2735
|
+
writeStore({
|
|
2736
|
+
team: {
|
|
2737
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2738
|
+
worker: { model: "openai/alpha" },
|
|
2739
|
+
},
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
fixture.onConfirm(async () => true);
|
|
2743
|
+
|
|
2744
|
+
applyOnce(fixture);
|
|
2745
|
+
await fixture.run("gentle:profiles");
|
|
2746
|
+
|
|
2747
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2748
|
+
const [, message] = fixture.confirmCalls[0];
|
|
2749
|
+
assert.match(message, /could not be read/, "the dialog must disclose that the current global routing is unreadable");
|
|
2750
|
+
const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
|
|
2751
|
+
assert.deepEqual(models, {
|
|
2752
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2753
|
+
worker: { model: "openai/alpha" },
|
|
2754
|
+
}, "confirmed apply proceeds as before despite the unreadable authority");
|
|
2755
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2756
|
+
assert.equal(store.active, "team", "active profile must be set to team when confirmed");
|
|
2757
|
+
});
|
|
2758
|
+
|
|
2759
|
+
test("applying a populated profile with explicit confirmation applies the routing diff", async (t) => {
|
|
2760
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2761
|
+
writeSettings();
|
|
2762
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2763
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "high" }, helper: { model: "openai/beta" } }, null, 2)}\n`);
|
|
2764
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2765
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" }, helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
2766
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2767
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2768
|
+
const tracerPath = join(fixture.root, ".pi", "agents", "tracer.md");
|
|
2769
|
+
writeMarkdown(tracerPath, "---\nname: tracer\ndescription: Tracer\n---\nbody\n");
|
|
2770
|
+
writeStore({
|
|
2771
|
+
team: {
|
|
2772
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2773
|
+
worker: { model: "openai/beta", thinking: "max" },
|
|
2774
|
+
tracer: { model: "openai/alpha" },
|
|
2775
|
+
},
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
fixture.onConfirm(async () => true);
|
|
2779
|
+
|
|
2780
|
+
applyOnce(fixture);
|
|
2781
|
+
await fixture.run("gentle:profiles");
|
|
2782
|
+
|
|
2783
|
+
assert.equal(fixture.confirmCalls.length, 1, "a populated global apply must ask for confirmation");
|
|
2784
|
+
const models = JSON.parse(readFileSync(fixture.globalPath, "utf8"));
|
|
2785
|
+
assert.deepEqual(models, {
|
|
2786
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2787
|
+
worker: { model: "openai/beta", thinking: "max" },
|
|
2788
|
+
tracer: { model: "openai/alpha" },
|
|
2789
|
+
}, "confirmed apply replaces global routing with the profile");
|
|
2790
|
+
const subagents = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2791
|
+
assert.equal("helper" in subagents.model_profiles, false, "the cleared agent loses its materialized route");
|
|
2792
|
+
assert.equal(subagents.model_profiles.worker?.model, "openai/beta", "the replaced route is materialized");
|
|
2793
|
+
assert.equal(subagents.model_profiles.tracer?.model, "openai/alpha", "the added agent is materialized");
|
|
2794
|
+
assert.doesNotMatch(readFileSync(helperPath, "utf8"), /^model:/m, "the cleared agent's frontmatter route is dropped");
|
|
2795
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2796
|
+
assert.equal(store.active, "team", "active profile must be set to team when confirmed");
|
|
2797
|
+
assert.deepEqual(fixture.liveSwitches, [
|
|
2798
|
+
{ kind: "model", provider: "nan", id: "glm5.3" },
|
|
2799
|
+
{ kind: "thinking", level: "max" },
|
|
2800
|
+
], "the live session switches to the profile's orchestrator");
|
|
2801
|
+
});
|
|
2802
|
+
|
|
2128
2803
|
test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
|
|
2129
2804
|
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2130
2805
|
writeSettings();
|
|
@@ -2218,7 +2893,7 @@ test("s snapshots current routing in place without applying or reopening the pro
|
|
|
2218
2893
|
});
|
|
2219
2894
|
assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
|
|
2220
2895
|
assert.equal(store.active, "z-active");
|
|
2221
|
-
assert.match(fixture.panels[0] ?? "", /enter
|
|
2896
|
+
assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
|
|
2222
2897
|
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
|
|
2223
2898
|
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
|
|
2224
2899
|
assert.equal(readFileSync(workerPath, "utf8"), before.worker);
|
|
@@ -2232,6 +2907,32 @@ test("s snapshots current routing in place without applying or reopening the pro
|
|
|
2232
2907
|
assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
|
|
2233
2908
|
});
|
|
2234
2909
|
|
|
2910
|
+
test("s snapshots the live session's orchestrator over the settings defaults", async (t) => {
|
|
2911
|
+
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2912
|
+
writeSettings();
|
|
2913
|
+
writeStore({
|
|
2914
|
+
"a-target": {},
|
|
2915
|
+
"z-active": { worker: { model: "openai/beta" } },
|
|
2916
|
+
}, "z-active");
|
|
2917
|
+
// The session runs live on openai/omega at low effort while settings.json still
|
|
2918
|
+
// defaults to nan/deepseek-v4-flash at high: the snapshot must capture the
|
|
2919
|
+
// session the user is actually in, not the default new sessions would get.
|
|
2920
|
+
fixture.setLiveModel("openai", "omega", "low");
|
|
2921
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2922
|
+
fixture.onInput((panel) => {
|
|
2923
|
+
panel.handleInput("s");
|
|
2924
|
+
panel.handleInput("\x1b");
|
|
2925
|
+
});
|
|
2926
|
+
await fixture.run("gentle:profiles");
|
|
2927
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2928
|
+
assert.deepEqual(
|
|
2929
|
+
store.profiles["a-target"].orchestrator,
|
|
2930
|
+
{ model: "openai/omega", thinking: "low" },
|
|
2931
|
+
"the orchestrator entry comes from the live session, not settings.json",
|
|
2932
|
+
);
|
|
2933
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings are untouched");
|
|
2934
|
+
});
|
|
2935
|
+
|
|
2235
2936
|
// /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
|
|
2236
2937
|
// by the snapshot `/gentle:profiles` performs with `s` on the current profile.
|
|
2237
2938
|
function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
|
|
@@ -2800,6 +3501,7 @@ test("applying a profile in a pinned repository re-pins the clone and writes no
|
|
|
2800
3501
|
assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
|
|
2801
3502
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
|
|
2802
3503
|
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized routing was written");
|
|
3504
|
+
assert.equal(fixture.confirmCalls.length, 0, "a repo-pinned apply never asks for confirmation");
|
|
2803
3505
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
|
|
2804
3506
|
});
|
|
2805
3507
|
|
|
@@ -2995,3 +3697,269 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => {
|
|
|
2995
3697
|
const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
|
|
2996
3698
|
assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
|
|
2997
3699
|
});
|
|
3700
|
+
|
|
3701
|
+
/** Reads the fixture's profiles store or fails the test: every panel-action fixture writes a valid store before acting. */
|
|
3702
|
+
function readValidProfilesStore(path: string) {
|
|
3703
|
+
const result = readProfilesFileResult(path);
|
|
3704
|
+
if (result.status !== "valid") throw new Error(`profiles store at ${path} is ${result.status}, expected valid`);
|
|
3705
|
+
return result.file;
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
test("Enter switches the live orchestrator, binds the profile, and writes nothing", async (t) => {
|
|
3709
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3710
|
+
writeSettings();
|
|
3711
|
+
writeStore({ team: { orchestrator: { model: "openai/beta", thinking: "high" }, worker: { model: "openai/alpha" } }, old: {} }, "old");
|
|
3712
|
+
const before = {
|
|
3713
|
+
store: readFileSync(storePath, "utf8"),
|
|
3714
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
3715
|
+
};
|
|
3716
|
+
resetSessionProfileBindingsForTesting();
|
|
3717
|
+
const notifications: Array<{ message: string; severity: string }> = [];
|
|
3718
|
+
const ctx = {
|
|
3719
|
+
cwd: fixture.root,
|
|
3720
|
+
hasUI: true,
|
|
3721
|
+
ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
|
|
3722
|
+
sessionManager: { getSessionId: () => "session-panel" },
|
|
3723
|
+
modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) },
|
|
3724
|
+
} as unknown as ExtensionContext;
|
|
3725
|
+
const models: unknown[] = [];
|
|
3726
|
+
const thinking: ThinkingLevel[] = [];
|
|
3727
|
+
const live = {
|
|
3728
|
+
setModel: async (model: unknown) => { models.push(model); return true; },
|
|
3729
|
+
setThinkingLevel(level: ThinkingLevel) { thinking.push(level); },
|
|
3730
|
+
getThinkingLevel(): ThinkingLevel { return "medium"; },
|
|
3731
|
+
};
|
|
3732
|
+
const file = readValidProfilesStore(storePath);
|
|
3733
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
|
|
3734
|
+
assert.deepEqual(models, [{ provider: "openai", id: "beta" }]);
|
|
3735
|
+
assert.deepEqual(thinking, ["high"]);
|
|
3736
|
+
const binding = readSessionProfileBinding("session-panel");
|
|
3737
|
+
assert.equal(binding?.name, "team");
|
|
3738
|
+
assert.equal(binding?.modelProfiles.worker?.model, "openai/alpha");
|
|
3739
|
+
assert.equal(readFileSync(storePath, "utf8"), before.store, "the profiles store is untouched");
|
|
3740
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "Pi settings are untouched");
|
|
3741
|
+
assert.equal(existsSync(fixture.globalPath), false, "no global models.json is written");
|
|
3742
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written");
|
|
3743
|
+
const applied = notifications.at(-1)?.message ?? "";
|
|
3744
|
+
assert.match(applied, /bound profile "team" to this session/);
|
|
3745
|
+
assert.match(applied, /shown as "team \(session\)"/);
|
|
3746
|
+
assert.match(applied, /This session now runs on openai\/beta · high/);
|
|
3747
|
+
assert.match(applied, /Subagents and reviewers use this session's routing snapshot/);
|
|
3748
|
+
assert.match(applied, /global routing, pins, and materialized stores are untouched/);
|
|
3749
|
+
assert.doesNotMatch(applied, /launch routing is unchanged/);
|
|
3750
|
+
resetSessionProfileBindingsForTesting();
|
|
3751
|
+
});
|
|
3752
|
+
|
|
3753
|
+
test("Enter switches only the selecting session's orchestrator and leaves shared defaults untouched", async (t) => {
|
|
3754
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3755
|
+
writeSettings();
|
|
3756
|
+
writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/beta" } }, old: {} }, "old");
|
|
3757
|
+
const beforeStore = readFileSync(storePath, "utf8");
|
|
3758
|
+
const beforeSettings = readFileSync(settingsPath, "utf8");
|
|
3759
|
+
resetSessionProfileBindingsForTesting();
|
|
3760
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3761
|
+
bindSessionProfile("other-session", "old", { worker: { model: "openai/old" } });
|
|
3762
|
+
const switches: string[] = [];
|
|
3763
|
+
const thinking: ThinkingLevel[] = [];
|
|
3764
|
+
const ctx = {
|
|
3765
|
+
cwd: fixture.root, hasUI: true,
|
|
3766
|
+
ui: { notify() {} },
|
|
3767
|
+
sessionManager: { getSessionId: () => "selecting-session" },
|
|
3768
|
+
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
|
|
3769
|
+
} as unknown as ExtensionContext;
|
|
3770
|
+
const live = {
|
|
3771
|
+
setModel: async (model: { provider: string; id: string }) => { switches.push(`${model.provider}/${model.id}`); return true; },
|
|
3772
|
+
setThinkingLevel: (level: ThinkingLevel) => { thinking.push(level); },
|
|
3773
|
+
getThinkingLevel: (): ThinkingLevel => "medium",
|
|
3774
|
+
} as unknown as LiveSession;
|
|
3775
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" });
|
|
3776
|
+
assert.deepEqual(switches, ["openai/alpha"]);
|
|
3777
|
+
assert.deepEqual(thinking, ["high"]);
|
|
3778
|
+
assert.equal(readSessionProfileBinding("selecting-session")?.name, "team");
|
|
3779
|
+
assert.equal(readSessionProfileBinding("other-session")?.modelProfiles.worker?.model, "openai/old");
|
|
3780
|
+
assert.equal(readSessionProfileBinding("unbound-session"), undefined);
|
|
3781
|
+
assert.equal(readFileSync(storePath, "utf8"), beforeStore);
|
|
3782
|
+
assert.equal(readFileSync(settingsPath, "utf8"), beforeSettings);
|
|
3783
|
+
assert.equal(existsSync(fixture.globalPath), false);
|
|
3784
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false);
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
test("reviewer routing uses the selecting session snapshot without leaking to other sessions", (t) => {
|
|
3788
|
+
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
3789
|
+
writeStore({ team: {} }, "team");
|
|
3790
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
3791
|
+
writeFileSync(fixture.globalPath, JSON.stringify({ "review-risk": { model: "openai/global" }, "review-reliability": { model: "openai/global" } }));
|
|
3792
|
+
resetSessionProfileBindingsForTesting();
|
|
3793
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3794
|
+
bindSessionProfile("selecting-session", "team", { "review-risk": { model: "openai/session", thinking: "high" } });
|
|
3795
|
+
const selected = __testing.readReviewerModelConfig(fixture.root, "selecting-session");
|
|
3796
|
+
assert.equal(selected["review-risk"]?.model, "openai/session");
|
|
3797
|
+
assert.equal(selected["review-reliability"], undefined, "a complete session snapshot never falls back per role");
|
|
3798
|
+
assert.equal(__testing.readReviewerModelConfig(fixture.root, "other-session")["review-risk"]?.model, "openai/global");
|
|
3799
|
+
assert.equal(__testing.readReviewerModelConfig(fixture.root, undefined)["review-risk"]?.model, "openai/global");
|
|
3800
|
+
});
|
|
3801
|
+
|
|
3802
|
+
test("Enter preserves session-only routing when the orchestrator is absent or cannot switch", async (t) => {
|
|
3803
|
+
for (const rejected of [false, true]) {
|
|
3804
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3805
|
+
writeSettings();
|
|
3806
|
+
writeStore({ team: {
|
|
3807
|
+
worker: { model: "openai/alpha" },
|
|
3808
|
+
...(rejected ? { orchestrator: { model: "openai/beta", thinking: "high" as const } } : {}),
|
|
3809
|
+
} }, undefined);
|
|
3810
|
+
const storeBefore = readFileSync(storePath, "utf8");
|
|
3811
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3812
|
+
resetSessionProfileBindingsForTesting();
|
|
3813
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3814
|
+
const notifications: string[] = [];
|
|
3815
|
+
let modelCalls = 0;
|
|
3816
|
+
let thinkingCalls = 0;
|
|
3817
|
+
const ctx = {
|
|
3818
|
+
cwd: fixture.root,
|
|
3819
|
+
hasUI: true,
|
|
3820
|
+
ui: { notify(message: string) { notifications.push(message); } },
|
|
3821
|
+
sessionManager: { getSessionId: () => "session-panel" },
|
|
3822
|
+
modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) },
|
|
3823
|
+
} as unknown as ExtensionContext;
|
|
3824
|
+
const live = {
|
|
3825
|
+
setModel: async () => { modelCalls++; return false; },
|
|
3826
|
+
setThinkingLevel() { thinkingCalls++; },
|
|
3827
|
+
getThinkingLevel(): ThinkingLevel { return "medium"; },
|
|
3828
|
+
};
|
|
3829
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }, {});
|
|
3830
|
+
assert.equal(modelCalls, rejected ? 1 : 0);
|
|
3831
|
+
assert.equal(thinkingCalls, 0);
|
|
3832
|
+
assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha");
|
|
3833
|
+
assert.equal(readFileSync(storePath, "utf8"), storeBefore);
|
|
3834
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
|
|
3835
|
+
assert.equal(existsSync(fixture.globalPath), false);
|
|
3836
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false);
|
|
3837
|
+
if (rejected) assert.match(notifications.at(-1) ?? "", /no authentication is configured for openai; this session keeps its current model/);
|
|
3838
|
+
else assert.doesNotMatch(notifications.at(-1) ?? "", /This session now runs/);
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
|
|
3842
|
+
test("a keeps the legacy global apply semantics", async (t) => {
|
|
3843
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3844
|
+
writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old");
|
|
3845
|
+
const ctx = {
|
|
3846
|
+
cwd: fixture.root,
|
|
3847
|
+
hasUI: true,
|
|
3848
|
+
ui: { notify() {}, confirm: async () => true },
|
|
3849
|
+
sessionManager: { getSessionId: () => "session-panel" },
|
|
3850
|
+
} as unknown as ExtensionContext;
|
|
3851
|
+
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3852
|
+
const file = readValidProfilesStore(storePath);
|
|
3853
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {});
|
|
3854
|
+
assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile");
|
|
3855
|
+
assert.ok(existsSync(fixture.globalPath), "the global routing is materialized");
|
|
3856
|
+
resetSessionProfileBindingsForTesting();
|
|
3857
|
+
});
|
|
3858
|
+
|
|
3859
|
+
test("Enter with a winning pin binds the session and never touches the pin layers", async (t) => {
|
|
3860
|
+
const { fixture, storePath, writeStore, writePin, localPinPath } = profilesStoreFixture(t);
|
|
3861
|
+
writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, undefined);
|
|
3862
|
+
writePin(localPinPath, "old");
|
|
3863
|
+
const pinBefore = readFileSync(localPinPath, "utf8");
|
|
3864
|
+
const storeBefore = readFileSync(storePath, "utf8");
|
|
3865
|
+
resetSessionProfileBindingsForTesting();
|
|
3866
|
+
const ctx = {
|
|
3867
|
+
cwd: fixture.root,
|
|
3868
|
+
hasUI: true,
|
|
3869
|
+
ui: { notify() {} },
|
|
3870
|
+
sessionManager: { getSessionId: () => "session-panel" },
|
|
3871
|
+
} as unknown as ExtensionContext;
|
|
3872
|
+
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3873
|
+
const file = readValidProfilesStore(storePath);
|
|
3874
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
|
|
3875
|
+
assert.equal(readSessionProfileBinding("session-panel")?.name, "team");
|
|
3876
|
+
assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched");
|
|
3877
|
+
assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched");
|
|
3878
|
+
assert.equal(existsSync(fixture.globalPath), false);
|
|
3879
|
+
resetSessionProfileBindingsForTesting();
|
|
3880
|
+
});
|
|
3881
|
+
|
|
3882
|
+
test("a session-bound panel renders the binding snapshot as the current routing", async (t) => {
|
|
3883
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3884
|
+
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3885
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
3886
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
3887
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3888
|
+
bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
|
|
3889
|
+
fixture.onInput((panel) => {
|
|
3890
|
+
const rendered = renderComponent(panel);
|
|
3891
|
+
assert.match(rendered, /Current routing \(effective\)/);
|
|
3892
|
+
assert.match(rendered, /openai\/gamma/, "the current routing is the session binding's snapshot");
|
|
3893
|
+
assert.doesNotMatch(rendered, /openai\/alpha/, "the global routing stays out of a bound session's panel");
|
|
3894
|
+
assert.match(
|
|
3895
|
+
rendered,
|
|
3896
|
+
/team \(session\) — launches resolve it ahead of pins/,
|
|
3897
|
+
"the session line states what the binding governs post-#1558 (the panel truncates long lines)",
|
|
3898
|
+
);
|
|
3899
|
+
assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives in the panel");
|
|
3900
|
+
panel.handleInput("\x1b");
|
|
3901
|
+
});
|
|
3902
|
+
await fixture.run("gentle:profiles");
|
|
3903
|
+
resetSessionProfileBindingsForTesting();
|
|
3904
|
+
});
|
|
3905
|
+
|
|
3906
|
+
test("the now line reports the live session's orchestrator over the settings defaults", async (t) => {
|
|
3907
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3908
|
+
writeSettings();
|
|
3909
|
+
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3910
|
+
// The session runs live on openai/omega at low effort while settings.json still
|
|
3911
|
+
// defaults to nan/deepseek-v4-flash at high: the panel's now line must report
|
|
3912
|
+
// what this session actually runs, not the default new sessions would get.
|
|
3913
|
+
fixture.setLiveModel("openai", "omega", "low");
|
|
3914
|
+
fixture.onInput((panel) => {
|
|
3915
|
+
const rendered = renderComponent(panel);
|
|
3916
|
+
assert.match(rendered, /now\s+openai\/omega/, "the now line shows the live session's orchestrator");
|
|
3917
|
+
assert.doesNotMatch(rendered, /now\s+nan\//, "the settings default stays off the now line when a live model runs");
|
|
3918
|
+
panel.handleInput("\x1b");
|
|
3919
|
+
});
|
|
3920
|
+
await fixture.run("gentle:profiles");
|
|
3921
|
+
});
|
|
3922
|
+
|
|
3923
|
+
test("the (session) marker survives a snapshot refresh of the panel list", async (t) => {
|
|
3924
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3925
|
+
writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
|
|
3926
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
3927
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
3928
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3929
|
+
bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
|
|
3930
|
+
fixture.onInput((panel) => {
|
|
3931
|
+
assert.match(renderComponent(panel), /team \(active\) \(session\)/);
|
|
3932
|
+
panel.handleInput("s");
|
|
3933
|
+
assert.match(renderComponent(panel), /Snapshot saved; live routing unchanged\./);
|
|
3934
|
+
assert.match(renderComponent(panel), /team \(active\) \(session\)/, "the refreshed list keeps the session marker");
|
|
3935
|
+
panel.handleInput("\x1b");
|
|
3936
|
+
});
|
|
3937
|
+
await fixture.run("gentle:profiles");
|
|
3938
|
+
// The snapshot must capture the routing the panel showed as current, so the
|
|
3939
|
+
// saved team profile carries the session binding's model, never the global
|
|
3940
|
+
// layer underneath it.
|
|
3941
|
+
const saved = readValidProfilesStore(storePath);
|
|
3942
|
+
assert.equal(saved.profiles.team?.worker?.model, "openai/gamma", "the saved profile carries the session-bound routing");
|
|
3943
|
+
assert.notEqual(saved.profiles.team?.worker?.model, "openai/alpha", "the global routing never leaks into the saved snapshot");
|
|
3944
|
+
resetSessionProfileBindingsForTesting();
|
|
3945
|
+
});
|
|
3946
|
+
|
|
3947
|
+
test("Enter without a parent session id fails loud and writes nothing", async (t) => {
|
|
3948
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3949
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined);
|
|
3950
|
+
const before = readFileSync(storePath, "utf8");
|
|
3951
|
+
const notifications: Array<{ message: string; severity: string }> = [];
|
|
3952
|
+
const ctx = {
|
|
3953
|
+
cwd: fixture.root,
|
|
3954
|
+
hasUI: true,
|
|
3955
|
+
ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } },
|
|
3956
|
+
} as unknown as ExtensionContext;
|
|
3957
|
+
const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
|
|
3958
|
+
const file = readValidProfilesStore(storePath);
|
|
3959
|
+
await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
|
|
3960
|
+
assert.equal(readSessionProfileBinding(undefined), undefined);
|
|
3961
|
+
assert.equal(readFileSync(storePath, "utf8"), before);
|
|
3962
|
+
assert.equal(existsSync(fixture.globalPath), false);
|
|
3963
|
+
assert.equal(notifications.at(-1)?.severity, "warning");
|
|
3964
|
+
resetSessionProfileBindingsForTesting();
|
|
3965
|
+
});
|