gentle-pi 3.7.0 → 4.1.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 +155 -10
- package/assets/agents/gentle-ai-explore.md +13 -6
- package/assets/agents/gentle-ai-verify.md +27 -6
- package/assets/agents/gentle-ai-worker.md +13 -15
- 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 +75 -169
- package/assets/orchestrator-memory.md +12 -26
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-skills.md +1 -1
- 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 +30 -38
- 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/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +956 -50
- 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/bundled-install.md +397 -0
- package/docs/delegated-verification.md +27 -4
- package/docs/gentle-agents-activity.md +537 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +165 -51
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +3 -3
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +364 -231
- package/docs/render-cache-performance.md +28 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +35 -0
- package/extensions/child-safety.ts +25 -0
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +1093 -326
- package/extensions/gentle-ai.ts +1636 -890
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +1619 -136
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +126 -20
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1424 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +254 -92
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +191 -58
- package/extensions/startup-banner.ts +71 -33
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +99 -0
- package/lib/agents-runner.ts +180 -107
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/auto-permission-classifier.ts +280 -0
- package/lib/auto-permission-runtime.ts +268 -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 +220 -0
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +195 -0
- package/lib/command-palette-catalog.ts +5 -10
- package/lib/command-palette.ts +25 -14
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +89 -27
- package/lib/gentle-shell-resume-hint.ts +176 -0
- 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/history-capture-policy.ts +95 -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 +5 -1
- package/lib/nan-provider.ts +249 -0
- package/lib/native-review-cli.ts +242 -180
- 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-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- 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/overlay-repaint.ts +26 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +73 -21
- package/lib/review-candidate-view.ts +115 -27
- 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-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +227 -56
- package/lib/review-sidebar-state.ts +224 -0
- 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 +527 -0
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +87 -9
- package/lib/shell-bar.ts +215 -30
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +42 -14
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +83 -15
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +84 -31
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +70 -13
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +383 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +119 -0
- package/lib/visual-customize-view.ts +481 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +317 -0
- package/package.json +20 -8
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +518 -0
- package/runtime/gentle-shell-launcher.mjs +87 -25
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- 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 +239 -177
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +225 -54
- 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 +8 -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 +83 -35
- 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/mirror-odd-routing.mjs +2 -2
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +90 -0
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +54 -19
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +91 -38
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +16 -21
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +30 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +97 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +467 -93
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/auto-mode-invariants.test.ts +258 -0
- package/tests/auto-mode-sdk.test.ts +57 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-classifier.test.ts +283 -0
- package/tests/auto-permission-runtime.test.ts +196 -0
- package/tests/autonomous-guard.test.ts +69 -1
- 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 +146 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- 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 +87 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-context-files.test.ts +255 -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 +94 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +596 -0
- package/tests/command-palette.test.ts +43 -7
- 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 -78
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +171 -3
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +69 -0
- package/tests/gentle-agents.test.ts +2804 -346
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +21 -4
- 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-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +566 -62
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +493 -14
- package/tests/gentle-ai.test.ts +1427 -236
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +896 -115
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +333 -65
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- 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 +4216 -119
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +333 -10
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +49 -19
- 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 +2158 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +389 -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-authority.test.ts +16 -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 +481 -0
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +239 -118
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +101 -4
- 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 +76 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
- package/tests/odd-routing-contract.test.ts +158 -50
- package/tests/orchestrator-budget.test.ts +43 -45
- 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 +8 -7
- 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/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +344 -123
- package/tests/packed-runner-owned-path.test.ts +80 -0
- 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 +10 -18
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +7 -14
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +569 -187
- package/tests/rdd-aware-verification-contract.test.ts +103 -40
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +237 -21
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +399 -12
- package/tests/review-contract-prompt.test.ts +138 -44
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +866 -6
- package/tests/review-controller-workspace-root.test.ts +206 -6
- 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 +342 -4
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +221 -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 +14 -37
- 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-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +594 -26
- 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-sidebar-state.test.ts +402 -0
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +202 -0
- package/tests/runtime-harness.mjs +186 -800
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +451 -0
- 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-messaging-grants.test.ts +255 -0
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +381 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +134 -2
- package/tests/shell-bar.test.ts +422 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +135 -32
- package/tests/shell-changes.test.ts +61 -62
- package/tests/shell-prompt.test.ts +94 -2
- 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 +351 -22
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +207 -3
- package/tests/shell-usage-view.test.ts +28 -1
- package/tests/shell-usage.test.ts +108 -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 +357 -2
- package/tests/startup-banner.test.ts +246 -4
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -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 +107 -20
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/theme-customization.test.ts +72 -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-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +139 -0
- package/tests/visual-customize-view.test.ts +696 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +271 -0
- package/tests/yolo-mode-runtime.test.ts +188 -0
- package/tests/yolo-mode.test.ts +289 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
- /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,19 +15,46 @@ 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";
|
|
23
|
-
import { installPackageAssets } from "../lib/
|
|
25
|
+
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);
|
|
@@ -211,10 +239,10 @@ test("missing package-local binaries give a direct recovery without attributing
|
|
|
211
239
|
test("registered Gentle Review tools render reusable rose lifecycle call rows", () => {
|
|
212
240
|
const tools = registeredGentleTools();
|
|
213
241
|
const cases = [
|
|
214
|
-
["gentle_review", { operation: "status" }, "
|
|
215
|
-
["gentle_review", { operation: "future-operation", secret: "/private" }, "
|
|
216
|
-
["gentle_review_scope", {}, "
|
|
217
|
-
["gentle_review_capture_group", {}, "
|
|
242
|
+
["gentle_review", { operation: "status" }, "status"],
|
|
243
|
+
["gentle_review", { operation: "future-operation", secret: "/private" }, ""],
|
|
244
|
+
["gentle_review_scope", {}, "scope"],
|
|
245
|
+
["gentle_review_capture_group", {}, "capture group"],
|
|
218
246
|
[
|
|
219
247
|
"gentle_review_capture",
|
|
220
248
|
{
|
|
@@ -224,17 +252,22 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
|
|
|
224
252
|
secret: "secret-value",
|
|
225
253
|
arbitrary: "arbitrary-value",
|
|
226
254
|
},
|
|
227
|
-
"
|
|
255
|
+
"capture",
|
|
228
256
|
],
|
|
229
257
|
] as const;
|
|
230
258
|
|
|
259
|
+
// This exhaustiveness check is scoped to the gentle_review* lifecycle
|
|
260
|
+
// family this test names, not every gentle_-prefixed tool: gentle_odd_phase
|
|
261
|
+
// is a plain status-report tool with no pending/running/failed rose
|
|
262
|
+
// lifecycle card of its own (see extensions/gentle-ai.ts).
|
|
231
263
|
assert.deepEqual(
|
|
232
264
|
[...new Set(cases.map(([name]) => name))].sort(),
|
|
233
|
-
[...tools.keys()].filter((name) => name.startsWith("
|
|
265
|
+
[...tools.keys()].filter((name) => name.startsWith("gentle_review")).sort(),
|
|
234
266
|
);
|
|
235
267
|
|
|
236
|
-
for (const [name, args,
|
|
268
|
+
for (const [name, args, operation] of cases) {
|
|
237
269
|
const tool = tools.get(name);
|
|
270
|
+
const title = (status: string) => ["🌹 rdd", [status, operation].filter(Boolean).join(" · ")].filter(Boolean).join(" ");
|
|
238
271
|
assert.ok(tool, `missing ${name}`);
|
|
239
272
|
const initial = tool.renderCall(args, lifecycleTheme, lifecycleContext());
|
|
240
273
|
const initialText = renderComponent(initial);
|
|
@@ -260,10 +293,14 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
|
|
|
260
293
|
assert.strictEqual(initial, running);
|
|
261
294
|
assert.strictEqual(running, completed);
|
|
262
295
|
assert.strictEqual(completed, failed);
|
|
263
|
-
assert.equal(cardTitle(initialText),
|
|
264
|
-
assert.equal(cardTitle(runningText),
|
|
265
|
-
assert.equal(cardTitle(completedText),
|
|
266
|
-
assert.
|
|
296
|
+
assert.equal(cardTitle(initialText), title("running")); assert.equal(cardTone(initialText), "warning");
|
|
297
|
+
assert.equal(cardTitle(runningText), title("running")); assert.equal(cardTone(runningText), "warning");
|
|
298
|
+
assert.equal(cardTitle(completedText), title("")); assert.equal(cardTone(completedText), "success");
|
|
299
|
+
assert.doesNotMatch(cardTitle(completedText), /completed/);
|
|
300
|
+
assert.match(completedText, /to expand/);
|
|
301
|
+
assert.equal((initialText.match(/╰/g) ?? []).length, 1, "running call owns the closing frame");
|
|
302
|
+
assert.equal((completedText.match(/╰/g) ?? []).length, 0, "final result owns the closing frame");
|
|
303
|
+
assert.equal(cardTitle(failedText), title("failed")); assert.equal(cardTone(failedText), "error");
|
|
267
304
|
assert.doesNotMatch(renderComponent(failed), /future-operation|secret|private/);
|
|
268
305
|
for (const forbiddenValue of ["lineage-id", "binding-id", "sha256:hash-value", "secret-value", "arbitrary-value"]) {
|
|
269
306
|
assert.doesNotMatch(failedText, new RegExp(forbiddenValue));
|
|
@@ -271,7 +308,7 @@ test("registered Gentle Review tools render reusable rose lifecycle call rows",
|
|
|
271
308
|
}
|
|
272
309
|
});
|
|
273
310
|
|
|
274
|
-
test("registered Gentle Review tools preserve result envelopes and
|
|
311
|
+
test("registered Gentle Review tools preserve result envelopes and preview useful collapsed results", async () => {
|
|
275
312
|
const tools = registeredGentleTools();
|
|
276
313
|
const scope = tools.get("gentle_review_scope");
|
|
277
314
|
const manifest = { version: 1, scopeByMode: { "100644": ["src/file.ts"] }, gitlinks: {} };
|
|
@@ -284,7 +321,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
|
|
|
284
321
|
{ manifest: encoded, sha256, cursor: 0 },
|
|
285
322
|
undefined,
|
|
286
323
|
undefined,
|
|
287
|
-
{ cwd: process.cwd() } as ExtensionContext,
|
|
324
|
+
{ cwd: process.cwd() } as unknown as ExtensionContext,
|
|
288
325
|
);
|
|
289
326
|
const visibleEnvelope = JSON.parse(result.content[0].text);
|
|
290
327
|
assert.deepEqual(visibleEnvelope, {
|
|
@@ -296,7 +333,7 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
|
|
|
296
333
|
});
|
|
297
334
|
assert.deepEqual(result.details, visibleEnvelope);
|
|
298
335
|
|
|
299
|
-
const resultText = "safe result\x1b[31m\nlineage=secret body=private";
|
|
336
|
+
const resultText = "safe result\x1b[31m\nlineage=secret body=private\nthird useful detail\nfourth expanded detail";
|
|
300
337
|
for (const name of ["gentle_review", "gentle_review_scope", "gentle_review_capture"]) {
|
|
301
338
|
const tool = tools.get(name);
|
|
302
339
|
assert.equal(typeof tool?.renderResult, "function", `${name} must define result rendering`);
|
|
@@ -307,14 +344,17 @@ test("registered Gentle Review tools preserve result envelopes and redact collap
|
|
|
307
344
|
]) {
|
|
308
345
|
const collapsed = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, options, lifecycleTheme, {}));
|
|
309
346
|
const collapsedBody = cardBody(collapsed);
|
|
310
|
-
assert.
|
|
311
|
-
assert.
|
|
312
|
-
assert.
|
|
347
|
+
assert.match(collapsedBody, /safe result[\s\S]*lineage=secret body=private[\s\S]*third useful detail/, `${name} previews actual result content, not redaction`);
|
|
348
|
+
assert.doesNotMatch(collapsedBody, /\d+ lines?\b|fourth expanded detail|to expand|\x1b\[/);
|
|
349
|
+
assert.equal(collapsedBody.split("\n").length, 3, `${name} has three useful collapsed rows`);
|
|
350
|
+
assert.match(collapsed, new RegExp(`<${options.isError ? "error" : options.isPartial ? "warning" : "success"}>│`), "host outcome preserves the semantic frame tone");
|
|
351
|
+
assert.equal((collapsed.match(/╰/g) ?? []).length, options.isPartial ? 0 : 1, "only final results close the frame");
|
|
313
352
|
}
|
|
314
353
|
const expanded = renderComponent(tool.renderResult({ content: [{ type: "text", text: resultText }] }, { expanded: true, isPartial: false, isError: true }, lifecycleTheme, {}));
|
|
315
|
-
assert.equal(cardBody(expanded).split("\n")[0], "safe result");
|
|
354
|
+
assert.equal(cardBody(expanded).split("\n")[0], "<error>safe result</error>");
|
|
316
355
|
assert.match(expanded, /safe result/);
|
|
317
|
-
assert.match(expanded, /lineage=secret body=private/);
|
|
356
|
+
assert.match(expanded, /lineage=secret body=private[\s\S]*third useful detail[\s\S]*fourth expanded detail/);
|
|
357
|
+
assert.equal((expanded.match(/╰/g) ?? []).length, 1, "expanded final result closes exactly one frame");
|
|
318
358
|
assert.doesNotMatch(expanded, /to expand/);
|
|
319
359
|
assert.doesNotMatch(cardBody(expanded), /\x1b\[/);
|
|
320
360
|
const nonText = renderComponent(tool.renderResult({ content: [{ type: "image", data: "opaque", mimeType: "image/png" }] }, { expanded: true, isPartial: false }, lifecycleTheme, {}));
|
|
@@ -371,11 +411,13 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
371
411
|
const liveSwitches: Array<{ kind: "model"; provider: string; id: string } | { kind: "thinking"; level: string }> = [];
|
|
372
412
|
let setModelResult = true;
|
|
373
413
|
let thinkingRejects = false;
|
|
414
|
+
let liveThinking = "medium";
|
|
374
415
|
createGentleAiExtension({ nativeReviewCli: null })({
|
|
375
416
|
on() {},
|
|
376
417
|
registerTool() {},
|
|
377
418
|
registerCommand(name, command) { commands.set(name, command); },
|
|
378
419
|
setModel: async (model: { provider: string; id: string }) => { liveSwitches.push({ kind: "model", provider: model.provider, id: model.id }); return setModelResult; },
|
|
420
|
+
getThinkingLevel: () => liveThinking,
|
|
379
421
|
setThinkingLevel: (level: string) => {
|
|
380
422
|
if (thinkingRejects) throw new Error(`thinking level ${level} is not supported by this model`);
|
|
381
423
|
liveSwitches.push({ kind: "thinking", level });
|
|
@@ -389,21 +431,40 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
389
431
|
const panels: string[] = [];
|
|
390
432
|
let onPanel = () => ({ type: "cancel", config: {} });
|
|
391
433
|
let onInput: ((panel: RoutingConsumerPanel) => void) | undefined;
|
|
434
|
+
// Scripted answers for ctx.ui.input, consumed in order. The Pi host ignores the
|
|
435
|
+
// placeholder and starts the field empty, so an empty answer is what Enter on an
|
|
436
|
+
// untouched field returns, and undefined is Esc.
|
|
437
|
+
const inputAnswers: Array<string | undefined> = [];
|
|
438
|
+
const inputPrompts: Array<{ title: string; placeholder: string | undefined }> = [];
|
|
392
439
|
// The orchestrator model is looked up in the registry before switching.
|
|
393
440
|
const registryModels = [
|
|
394
441
|
{ provider: "openai", id: "alpha" },
|
|
395
442
|
{ provider: "openai", id: "beta" },
|
|
396
443
|
{ provider: "nan", id: "glm5.3" },
|
|
397
444
|
];
|
|
445
|
+
let onConfirm: (title: string, message: string) => Promise<boolean> = async () => true;
|
|
446
|
+
const confirmCalls: Array<[string, string]> = [];
|
|
398
447
|
const ctx = {
|
|
399
448
|
cwd: root,
|
|
400
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" },
|
|
401
453
|
modelRegistry: {
|
|
402
454
|
getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
|
|
403
455
|
find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
|
|
404
456
|
},
|
|
405
457
|
ui: {
|
|
458
|
+
confirm: async (title: string, message: string) => {
|
|
459
|
+
confirmCalls.push([title, message]);
|
|
460
|
+
return onConfirm(title, message);
|
|
461
|
+
},
|
|
406
462
|
notify(message: string, severity: string) { notifications.push({ message, severity }); },
|
|
463
|
+
input: async (title: string, placeholder?: string) => {
|
|
464
|
+
inputPrompts.push({ title, placeholder });
|
|
465
|
+
assert.ok(inputAnswers.length > 0, `unexpected input prompt: ${title}`);
|
|
466
|
+
return inputAnswers.shift();
|
|
467
|
+
},
|
|
407
468
|
custom: async (factory: (tui: unknown, theme: Theme, keybindings: unknown, done: (result: unknown) => void) => RoutingConsumerPanel) => {
|
|
408
469
|
let result: unknown;
|
|
409
470
|
const panel = factory(fixtureTui, { fg: (_color: string, text: string) => text } as unknown as Theme, undefined, (value) => { result = value; });
|
|
@@ -423,10 +484,18 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
|
|
|
423
484
|
tui: fixtureTui as { terminal: { rows: number } },
|
|
424
485
|
panelVisits: () => panelVisits,
|
|
425
486
|
liveSwitches,
|
|
487
|
+
setLiveModel(provider: string, id: string, thinking: string) {
|
|
488
|
+
(ctx as { model?: { provider: string; id: string } }).model = { provider, id };
|
|
489
|
+
liveThinking = thinking;
|
|
490
|
+
},
|
|
426
491
|
refuseSetModel() { setModelResult = false; },
|
|
427
492
|
rejectThinkingLevel() { thinkingRejects = true; },
|
|
493
|
+
confirmCalls,
|
|
494
|
+
onConfirm(handler: (title: string, message: string) => Promise<boolean>) { onConfirm = handler; },
|
|
428
495
|
onPanel(action: typeof onPanel) { onPanel = action; },
|
|
429
496
|
onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
|
|
497
|
+
answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
|
|
498
|
+
inputPrompts,
|
|
430
499
|
run: (name: string) => commands.get(name)!.handler("", ctx),
|
|
431
500
|
};
|
|
432
501
|
}
|
|
@@ -685,7 +754,7 @@ test("session startup reports invalid project routing without mutating the profi
|
|
|
685
754
|
registerCommand() {},
|
|
686
755
|
registerTool() {},
|
|
687
756
|
} as unknown as ExtensionAPI;
|
|
688
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
757
|
+
createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } })(pi);
|
|
689
758
|
const sessionStart = handlers.get("session_start");
|
|
690
759
|
assert.equal(typeof sessionStart, "function");
|
|
691
760
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
@@ -757,8 +826,8 @@ test("managed routing timeout leaves its profile, agent, and manifest unchanged"
|
|
|
757
826
|
});
|
|
758
827
|
|
|
759
828
|
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
760
|
-
installPackageAssets(root, false, ["
|
|
761
|
-
const agentPath = join(agentHome, "agents", "
|
|
829
|
+
installPackageAssets(root, false, ["delegation"]);
|
|
830
|
+
const agentPath = join(agentHome, "agents", "gentle-ai-worker.md");
|
|
762
831
|
const manifestPath = join(agentHome, "gentle-ai", "managed-assets.json");
|
|
763
832
|
const profilePath = join(agentHome, "subagents.json");
|
|
764
833
|
const profileBefore = "{\n \"unrelated\": true\n}\n";
|
|
@@ -771,7 +840,7 @@ test("managed routing timeout leaves its profile, agent, and manifest unchanged"
|
|
|
771
840
|
);
|
|
772
841
|
|
|
773
842
|
assert.throws(
|
|
774
|
-
() => applyModelConfig(root, { "
|
|
843
|
+
() => applyModelConfig(root, { "gentle-ai-worker": { model: "test/managed", thinking: "high" } }),
|
|
775
844
|
/Timed out acquiring managed-assets lock file/i,
|
|
776
845
|
);
|
|
777
846
|
assert.equal(readFileSync(profilePath, "utf8"), profileBefore);
|
|
@@ -803,8 +872,8 @@ test("a later alias keeps managed-root precedence and manifest ownership", (t) =
|
|
|
803
872
|
process.env.GENTLE_PI_AGENT_HOME = agentHome;
|
|
804
873
|
process.env.HOME = home;
|
|
805
874
|
process.env.USERPROFILE = home;
|
|
806
|
-
installPackageAssets(cwd, false, ["
|
|
807
|
-
writeMarkdown(join(intervening, "
|
|
875
|
+
installPackageAssets(cwd, false, ["delegation"]);
|
|
876
|
+
writeMarkdown(join(intervening, "gentle-ai-worker.md"), "---\nname: gentle-ai-worker\n---\nintervening override\n");
|
|
808
877
|
mkdirSync(home, { recursive: true });
|
|
809
878
|
try {
|
|
810
879
|
symlinkSync(managed, alias, process.platform === "win32" ? "junction" : "dir");
|
|
@@ -813,13 +882,13 @@ test("a later alias keeps managed-root precedence and manifest ownership", (t) =
|
|
|
813
882
|
return;
|
|
814
883
|
}
|
|
815
884
|
|
|
816
|
-
const selected = __testing.listDiscoverableAgents(cwd).find((agent) => agent.name === "
|
|
817
|
-
assert.equal(selected?.filePath, join(managed, "
|
|
818
|
-
applyModelConfig(cwd, { "
|
|
885
|
+
const selected = __testing.listDiscoverableAgents(cwd).find((agent) => agent.name === "gentle-ai-worker");
|
|
886
|
+
assert.equal(selected?.filePath, join(managed, "gentle-ai-worker.md"));
|
|
887
|
+
applyModelConfig(cwd, { "gentle-ai-worker": { model: "test/managed", thinking: "high" } });
|
|
819
888
|
const manifest = JSON.parse(readFileSync(join(agentHome, "gentle-ai", "managed-assets.json"), "utf8")) as { assets: Record<string, string> };
|
|
820
|
-
const routed = readFileSync(join(managed, "
|
|
889
|
+
const routed = readFileSync(join(managed, "gentle-ai-worker.md"), "utf8");
|
|
821
890
|
assert.match(routed, /^model: test\/managed$/m);
|
|
822
|
-
assert.equal(manifest.assets["agents/
|
|
891
|
+
assert.equal(manifest.assets["agents/gentle-ai-worker.md"], createHash("sha256").update(routed).digest("hex"));
|
|
823
892
|
});
|
|
824
893
|
|
|
825
894
|
test("runtime guidance keeps review policy out of the static orchestrator and technical reference", () => {
|
|
@@ -845,7 +914,7 @@ test("runtime guidance keeps review policy out of the static orchestrator and te
|
|
|
845
914
|
}
|
|
846
915
|
|
|
847
916
|
const orchestrator = readFileSync("assets/orchestrator.md", "utf8")
|
|
848
|
-
+
|
|
917
|
+
+ readDelegationDetail();
|
|
849
918
|
assert.match(orchestrator, /injects the mirrored provider-bundle review execution contract/);
|
|
850
919
|
assert.match(orchestrator, /this package invents no lifecycle instructions/);
|
|
851
920
|
for (const lifecycleMarker of ["review-risk", "review-reliability", "review-resilience", "review-readability", "Authority-First Terminal Procedure", "reconcile-terminal-mirrors"]) {
|
|
@@ -939,7 +1008,10 @@ test("ordinary START adopts the committed-range selectors the provider just offe
|
|
|
939
1008
|
targetStatus: async () => offeredCommittedRangeStatus(baseCommit, baseTree, candidateTree),
|
|
940
1009
|
start: async (request: Record<string, unknown>) => { starts.push(request); return startedReviewResult(); },
|
|
941
1010
|
} as unknown as NativeReviewCli;
|
|
942
|
-
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);
|
|
943
1015
|
assert.equal(result.operation, "start");
|
|
944
1016
|
assert.equal(starts.length, 1);
|
|
945
1017
|
assert.equal(starts[0]?.baseRef, baseCommit);
|
|
@@ -1077,7 +1149,21 @@ test("ordinary START reports candidate-owner preparation failure as pre-native n
|
|
|
1077
1149
|
});
|
|
1078
1150
|
});
|
|
1079
1151
|
|
|
1080
|
-
test("
|
|
1152
|
+
test("retired SDD startup flag is not registered or imported", () => {
|
|
1153
|
+
const flags: string[] = [];
|
|
1154
|
+
const pi = {
|
|
1155
|
+
on() {},
|
|
1156
|
+
registerCommand() {},
|
|
1157
|
+
registerTool() {},
|
|
1158
|
+
registerFlag(name: string) { flags.push(name); },
|
|
1159
|
+
} as unknown as ExtensionAPI;
|
|
1160
|
+
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1161
|
+
assert.ok(!flags.includes("gentle-sdd-change"));
|
|
1162
|
+
const source = readFileSync(new URL("../extensions/gentle-ai.ts", import.meta.url), "utf8");
|
|
1163
|
+
assert.doesNotMatch(source, /from ["']\.\.\/lib\/sdd-preflight\.ts["']/);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("agent model discovery prioritizes Judgment Day agents", (t) => {
|
|
1081
1167
|
const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-agents-"));
|
|
1082
1168
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
1083
1169
|
writeMarkdown(join(root, "zeta.md"), "name: zeta\n");
|
|
@@ -1094,12 +1180,12 @@ test("agent model discovery prioritizes SDD and Judgment Day agents", (t) => {
|
|
|
1094
1180
|
assert.deepEqual(
|
|
1095
1181
|
ordered.map((agent) => agent.name),
|
|
1096
1182
|
[
|
|
1097
|
-
"sdd-init",
|
|
1098
|
-
"sdd-apply",
|
|
1099
1183
|
"jd-judge-a",
|
|
1100
1184
|
"jd-judge-b",
|
|
1101
1185
|
"jd-fix-agent",
|
|
1102
1186
|
"alpha",
|
|
1187
|
+
"sdd-apply",
|
|
1188
|
+
"sdd-init",
|
|
1103
1189
|
"zeta",
|
|
1104
1190
|
],
|
|
1105
1191
|
);
|
|
@@ -1195,6 +1281,34 @@ test("model panel render does not auto-apply the Gentle theme and sanitizes agen
|
|
|
1195
1281
|
assert.doesNotMatch(plain, /\[31m/);
|
|
1196
1282
|
});
|
|
1197
1283
|
|
|
1284
|
+
test("model panel fills the terminal height like the profiles panel", () => {
|
|
1285
|
+
const lines = __testing.renderSddModelPanel({}, ["openai/gpt-5.5"], ["safe-agent"], 72, undefined, 40);
|
|
1286
|
+
assert.equal(lines.length, 40);
|
|
1287
|
+
const plain = lines.map(stripAnsi);
|
|
1288
|
+
assert.match(plain[0] ?? "", /^╭─+╮$/);
|
|
1289
|
+
assert.match(plain[39] ?? "", /^╰─+╯$/);
|
|
1290
|
+
for (const line of plain) assert.equal(line.length, 72);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("model panel lists grow with the terminal height instead of a fixed window", () => {
|
|
1294
|
+
const agents = Array.from({ length: 40 }, (_, i) => `agent-${String(i).padStart(2, "0")}`);
|
|
1295
|
+
const models = Array.from({ length: 60 }, (_, i) => `provider/model-${String(i).padStart(2, "0")}`);
|
|
1296
|
+
const agentLines = __testing
|
|
1297
|
+
.renderSddModelPanel({}, models, agents, 100, undefined, 40)
|
|
1298
|
+
.map(stripAnsi);
|
|
1299
|
+
assert.equal(agentLines.length, 40);
|
|
1300
|
+
// 40 rows minus 15 rows of chrome: every remaining row lists an agent or "Set all".
|
|
1301
|
+
assert.equal(agentLines.filter((line) => /(agent-\d\d|Set all agents)\s+model=/.test(line)).length, 25);
|
|
1302
|
+
assert.ok(agentLines.some((line) => /x export/.test(line)));
|
|
1303
|
+
|
|
1304
|
+
const pickerLines = __testing
|
|
1305
|
+
.renderSddModelPanel({}, models, agents, 100, undefined, 40, ["\r"])
|
|
1306
|
+
.map(stripAnsi);
|
|
1307
|
+
assert.equal(pickerLines.length, 40);
|
|
1308
|
+
// 40 rows minus 8 rows of chrome.
|
|
1309
|
+
assert.equal(pickerLines.filter((line) => /provider\/model-\d\d/.test(line)).length, 32);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1198
1312
|
test("model panel render uses the Pi-provided current theme when supplied", () => {
|
|
1199
1313
|
const currentTheme = {
|
|
1200
1314
|
fg(_color: string, text: string): string {
|
|
@@ -1249,7 +1363,7 @@ test("delivery commands bypass RDD under every mode outcome while command safety
|
|
|
1249
1363
|
cwd: process.cwd(),
|
|
1250
1364
|
hasUI: true,
|
|
1251
1365
|
ui: { confirm: async () => true },
|
|
1252
|
-
} as ExtensionContext;
|
|
1366
|
+
} as unknown as ExtensionContext;
|
|
1253
1367
|
|
|
1254
1368
|
for (const command of commands) {
|
|
1255
1369
|
const result = await toolCall!({ toolName: "bash", input: { command } }, ctx);
|
|
@@ -1281,6 +1395,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1281
1395
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1282
1396
|
const emitted: EmittedEvent[] = [];
|
|
1283
1397
|
const sequence: string[] = [];
|
|
1398
|
+
const confirmation = confirmationObserver();
|
|
1284
1399
|
let confirm!: () => Promise<boolean>;
|
|
1285
1400
|
const pi = {
|
|
1286
1401
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1291,7 +1406,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1291
1406
|
sequence.push(
|
|
1292
1407
|
channel === "herdr:blocked"
|
|
1293
1408
|
? `herdr:${"active" in data && data.active ? "active" : "inactive"}`
|
|
1294
|
-
: `event:${data.state}`,
|
|
1409
|
+
: `event:${"state" in data ? data.state : "unknown"}`,
|
|
1295
1410
|
);
|
|
1296
1411
|
emitted.push({ channel, data } as EmittedEvent);
|
|
1297
1412
|
},
|
|
@@ -1299,7 +1414,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1299
1414
|
registerCommand() {},
|
|
1300
1415
|
registerTool() {},
|
|
1301
1416
|
} as unknown as ExtensionAPI;
|
|
1302
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1417
|
+
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1303
1418
|
const toolCall = handlers.get("tool_call");
|
|
1304
1419
|
assert.equal(typeof toolCall, "function");
|
|
1305
1420
|
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-request-"));
|
|
@@ -1310,10 +1425,11 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1310
1425
|
ui: {
|
|
1311
1426
|
confirm: async () => {
|
|
1312
1427
|
sequence.push("confirm");
|
|
1428
|
+
confirmation.observe();
|
|
1313
1429
|
return confirm();
|
|
1314
1430
|
},
|
|
1315
1431
|
},
|
|
1316
|
-
} as ExtensionContext;
|
|
1432
|
+
} as unknown as ExtensionContext;
|
|
1317
1433
|
let resolveConfirmation!: (approved: boolean) => void;
|
|
1318
1434
|
confirm = () => new Promise<boolean>((resolve) => { resolveConfirmation = resolve; });
|
|
1319
1435
|
const denied = toolCall!({
|
|
@@ -1321,7 +1437,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc
|
|
|
1321
1437
|
input: { command: "git rebase main --secret-command-content" },
|
|
1322
1438
|
}, ctx);
|
|
1323
1439
|
|
|
1324
|
-
await
|
|
1440
|
+
await confirmation.waitFor(1);
|
|
1325
1441
|
assert.equal(emitted[0].channel, "pi-permission-system:permission-request");
|
|
1326
1442
|
assert.equal(emitted[0].data.state, "waiting");
|
|
1327
1443
|
assert.deepEqual(emitted[1], {
|
|
@@ -1425,6 +1541,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1425
1541
|
const createHarness = () => {
|
|
1426
1542
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1427
1543
|
const emitted: EmittedEvent[] = [];
|
|
1544
|
+
const confirmation = confirmationObserver();
|
|
1428
1545
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1429
1546
|
const pi = {
|
|
1430
1547
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1434,8 +1551,8 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1434
1551
|
registerCommand() {},
|
|
1435
1552
|
registerTool() {},
|
|
1436
1553
|
} as unknown as ExtensionAPI;
|
|
1437
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1438
|
-
return { handlers, emitted, confirmations };
|
|
1554
|
+
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1555
|
+
return { handlers, emitted, confirmations, confirmation };
|
|
1439
1556
|
};
|
|
1440
1557
|
const first = createHarness();
|
|
1441
1558
|
const second = createHarness();
|
|
@@ -1445,12 +1562,15 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1445
1562
|
cwd,
|
|
1446
1563
|
hasUI: true,
|
|
1447
1564
|
ui: {
|
|
1448
|
-
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
|
+
}),
|
|
1449
1569
|
},
|
|
1450
|
-
} as ExtensionContext);
|
|
1570
|
+
} as unknown as ExtensionContext);
|
|
1451
1571
|
const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations));
|
|
1452
1572
|
const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations));
|
|
1453
|
-
await
|
|
1573
|
+
await first.confirmation.waitFor(2);
|
|
1454
1574
|
assert.deepEqual(first.emitted.map(({ channel, data }) => ({ channel, state: data.state, active: data.active })), [
|
|
1455
1575
|
{ channel: "pi-permission-system:permission-request", state: "waiting", active: undefined },
|
|
1456
1576
|
{ channel: "herdr:blocked", state: undefined, active: true },
|
|
@@ -1473,7 +1593,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1473
1593
|
]);
|
|
1474
1594
|
|
|
1475
1595
|
const independentRequest = second.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --independent-command" } }, context(second.confirmations));
|
|
1476
|
-
await
|
|
1596
|
+
await second.confirmation.waitFor(1);
|
|
1477
1597
|
assert.equal(second.emitted.filter(({ channel }) => channel === "herdr:blocked").length, 1);
|
|
1478
1598
|
assert.equal(second.emitted.find(({ channel }) => channel === "herdr:blocked")?.data.active, true);
|
|
1479
1599
|
assert.equal(second.confirmations.length, 1);
|
|
@@ -1495,7 +1615,8 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio
|
|
|
1495
1615
|
});
|
|
1496
1616
|
|
|
1497
1617
|
|
|
1498
|
-
|
|
1618
|
+
for (const channel of ["rpiv:ask-user:blocked", "gentle-pi:ask-user-question:blocked"]) {
|
|
1619
|
+
test(`${channel} emits only a private, balanced Herdr projection`, () => {
|
|
1499
1620
|
type HerdrBlockedEvent = { active: boolean; label?: string };
|
|
1500
1621
|
const eventHandlers = new Map<string, (data: unknown) => void>();
|
|
1501
1622
|
const published: Array<{ channel: string; data: unknown }> = [];
|
|
@@ -1518,9 +1639,15 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
|
|
|
1518
1639
|
registerTool() {},
|
|
1519
1640
|
} as unknown as ExtensionAPI;
|
|
1520
1641
|
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1521
|
-
|
|
1642
|
+
const privateHostDiscovery = "gentle:yolo:host-ui";
|
|
1643
|
+
assert.equal(eventHandlers.has(privateHostDiscovery), true);
|
|
1644
|
+
assert.deepEqual([...eventHandlers.keys()].filter(name => name !== privateHostDiscovery).sort(), [
|
|
1645
|
+
"gentle-pi:ask-user-choice:blocked", "gentle-pi:ask-user-question:blocked", "rpiv:ask-user:blocked",
|
|
1646
|
+
], "only the original three blocked channels remain besides the known private host adapter");
|
|
1647
|
+
assert.deepEqual(published, [], "registering host discovery emits no public event or private payload");
|
|
1522
1648
|
assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
|
|
1523
1649
|
assert.equal(eventHandlers.has("rpiv:ask-user:blocked"), true);
|
|
1650
|
+
assert.equal(eventHandlers.has("gentle-pi:ask-user-question:blocked"), true);
|
|
1524
1651
|
|
|
1525
1652
|
const source = {
|
|
1526
1653
|
active: true,
|
|
@@ -1530,23 +1657,29 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
|
|
|
1530
1657
|
command: "private questionnaire command",
|
|
1531
1658
|
arbitrary: { nested: "private questionnaire field" },
|
|
1532
1659
|
};
|
|
1533
|
-
|
|
1534
|
-
assert.
|
|
1660
|
+
eventHandlers.get(privateHostDiscovery)!(source);
|
|
1661
|
+
assert.deepEqual(published, [], "private discovery ignores questionnaire data instead of relaying it");
|
|
1662
|
+
assert.deepEqual(herdrEvents, []);
|
|
1663
|
+
pi.events.emit(channel, source);
|
|
1664
|
+
assert.strictEqual(published[0]?.data, source, "the questionnaire event remains the source event");
|
|
1535
1665
|
assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
|
|
1536
1666
|
assert.doesNotMatch(JSON.stringify(herdrEvents), /private questionnaire|questionnaire-path/i);
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
pi.events.emit(
|
|
1542
|
-
pi.events.emit(
|
|
1667
|
+
assert.deepEqual(published.filter(event => event.channel !== channel), [
|
|
1668
|
+
{ channel: "herdr:blocked", data: { active: true, label: "Questionnaire awaiting input" } },
|
|
1669
|
+
], "host discovery adds no new public projection or sensitive-content leakage");
|
|
1670
|
+
|
|
1671
|
+
pi.events.emit(channel, { active: true, duplicate: true });
|
|
1672
|
+
pi.events.emit(channel, { active: "true" });
|
|
1673
|
+
pi.events.emit(channel, { active: null });
|
|
1674
|
+
pi.events.emit(channel, []);
|
|
1675
|
+
pi.events.emit(channel, null);
|
|
1543
1676
|
pi.events.emit("rpiv:ask-user:other", { active: false });
|
|
1544
1677
|
assert.deepEqual(herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
|
|
1545
1678
|
|
|
1546
|
-
pi.events.emit(
|
|
1547
|
-
pi.events.emit(
|
|
1548
|
-
pi.events.emit(
|
|
1549
|
-
pi.events.emit(
|
|
1679
|
+
pi.events.emit(channel, { active: false });
|
|
1680
|
+
pi.events.emit(channel, { active: false, duplicate: true });
|
|
1681
|
+
pi.events.emit(channel, { active: true });
|
|
1682
|
+
pi.events.emit(channel, { active: false });
|
|
1550
1683
|
assert.deepEqual(herdrEvents, [
|
|
1551
1684
|
{ active: true, label: "Questionnaire awaiting input" },
|
|
1552
1685
|
{ active: false },
|
|
@@ -1555,7 +1688,9 @@ test("RPIV questionnaire blockers emit only a private, balanced Herdr projection
|
|
|
1555
1688
|
]);
|
|
1556
1689
|
});
|
|
1557
1690
|
|
|
1558
|
-
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
test("Herdr preserves the initial label and balanced edges across overlapping sources", async () => {
|
|
1559
1694
|
type ToolCallHandler = (
|
|
1560
1695
|
event: { toolName: string; input: unknown },
|
|
1561
1696
|
ctx: ExtensionContext,
|
|
@@ -1565,6 +1700,7 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
|
|
|
1565
1700
|
const handlers = new Map<string, ToolCallHandler>();
|
|
1566
1701
|
const eventHandlers = new Map<string, (data: unknown) => void>();
|
|
1567
1702
|
const herdrEvents: HerdrBlockedEvent[] = [];
|
|
1703
|
+
const confirmation = confirmationObserver();
|
|
1568
1704
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1569
1705
|
const pi = {
|
|
1570
1706
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1584,15 +1720,15 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
|
|
|
1584
1720
|
registerCommand() {},
|
|
1585
1721
|
registerTool() {},
|
|
1586
1722
|
} as unknown as ExtensionAPI;
|
|
1587
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1723
|
+
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1588
1724
|
const context = {
|
|
1589
1725
|
cwd: process.cwd(),
|
|
1590
1726
|
hasUI: true,
|
|
1591
1727
|
ui: {
|
|
1592
|
-
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
|
|
1728
|
+
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
|
|
1593
1729
|
},
|
|
1594
|
-
} as ExtensionContext;
|
|
1595
|
-
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")! };
|
|
1596
1732
|
};
|
|
1597
1733
|
|
|
1598
1734
|
const guardedFirst = createHarness();
|
|
@@ -1600,34 +1736,53 @@ test("Herdr coordinates guarded confirmations and RPIV labels without inactive r
|
|
|
1600
1736
|
{ toolName: "bash", input: { command: "git rebase main" } },
|
|
1601
1737
|
guardedFirst.context,
|
|
1602
1738
|
);
|
|
1603
|
-
await
|
|
1739
|
+
await guardedFirst.confirmation.waitFor(1);
|
|
1604
1740
|
guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
|
|
1605
1741
|
guardedFirst.confirmations[0]!(true);
|
|
1606
1742
|
assert.equal(await guardedRequest, undefined);
|
|
1607
1743
|
assert.deepEqual(guardedFirst.herdrEvents, [
|
|
1608
1744
|
{ active: true, label: "Guarded command confirmation" },
|
|
1609
|
-
{ active: true, label: "Questionnaire awaiting input" },
|
|
1610
1745
|
]);
|
|
1611
1746
|
guardedFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
|
|
1612
1747
|
assert.deepEqual(guardedFirst.herdrEvents, [
|
|
1613
1748
|
{ active: true, label: "Guarded command confirmation" },
|
|
1614
|
-
{ active: true, label: "Questionnaire awaiting input" },
|
|
1615
1749
|
{ active: false },
|
|
1616
1750
|
]);
|
|
1617
1751
|
|
|
1752
|
+
// Each event channel is independent, even when native and legacy producers overlap.
|
|
1753
|
+
const channels = ["gentle-pi:ask-user-question:blocked", "rpiv:ask-user:blocked", "gentle-pi:ask-user-choice:blocked"];
|
|
1754
|
+
for (const lastChannel of channels) {
|
|
1755
|
+
const overlap = createHarness();
|
|
1756
|
+
for (const channel of channels) overlap.pi.events.emit(channel, { active: true });
|
|
1757
|
+
const request = overlap.toolCall(
|
|
1758
|
+
{ toolName: "bash", input: { command: "git rebase main" } }, overlap.context,
|
|
1759
|
+
);
|
|
1760
|
+
await overlap.confirmation.waitFor(1);
|
|
1761
|
+
for (const channel of channels.filter((channel) => channel !== lastChannel)) {
|
|
1762
|
+
overlap.pi.events.emit(channel, { active: false });
|
|
1763
|
+
overlap.pi.events.emit(channel, { active: false });
|
|
1764
|
+
}
|
|
1765
|
+
overlap.confirmations[0]!(false);
|
|
1766
|
+
await request;
|
|
1767
|
+
assert.deepEqual(overlap.herdrEvents, [{ active: true, label: "Questionnaire awaiting input" }]);
|
|
1768
|
+
overlap.pi.events.emit(lastChannel, { active: false });
|
|
1769
|
+
assert.deepEqual(overlap.herdrEvents, [
|
|
1770
|
+
{ active: true, label: "Questionnaire awaiting input" }, { active: false },
|
|
1771
|
+
]);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1618
1774
|
const questionnaireFirst = createHarness();
|
|
1619
1775
|
questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: true });
|
|
1620
1776
|
const questionnaireRequest = questionnaireFirst.toolCall(
|
|
1621
1777
|
{ toolName: "bash", input: { command: "git rebase main" } },
|
|
1622
1778
|
questionnaireFirst.context,
|
|
1623
1779
|
);
|
|
1624
|
-
await
|
|
1780
|
+
await questionnaireFirst.confirmation.waitFor(1);
|
|
1625
1781
|
questionnaireFirst.pi.events.emit("rpiv:ask-user:blocked", { active: false });
|
|
1626
1782
|
questionnaireFirst.confirmations[0]!(false);
|
|
1627
1783
|
await questionnaireRequest;
|
|
1628
1784
|
assert.deepEqual(questionnaireFirst.herdrEvents, [
|
|
1629
1785
|
{ active: true, label: "Questionnaire awaiting input" },
|
|
1630
|
-
{ active: true, label: "Guarded command confirmation" },
|
|
1631
1786
|
{ active: false },
|
|
1632
1787
|
]);
|
|
1633
1788
|
});
|
|
@@ -1642,6 +1797,7 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1642
1797
|
const eventHandlers = new Map<string, (data: unknown) => void>();
|
|
1643
1798
|
const herdrEvents: HerdrBlockedEvent[] = [];
|
|
1644
1799
|
const choiceEvents: Array<{ active: boolean }> = [];
|
|
1800
|
+
const confirmation = confirmationObserver();
|
|
1645
1801
|
const confirmations: Array<(approved: boolean) => void> = [];
|
|
1646
1802
|
const pi = {
|
|
1647
1803
|
on(name: string, handler: ToolCallHandler) {
|
|
@@ -1661,9 +1817,14 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1661
1817
|
registerCommand() {},
|
|
1662
1818
|
registerTool() {},
|
|
1663
1819
|
} as unknown as ExtensionAPI;
|
|
1664
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1820
|
+
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1665
1821
|
assert.equal(eventHandlers.has("gentle-pi:ask-user-choice:blocked"), true);
|
|
1666
1822
|
|
|
1823
|
+
for (const malformed of [null, [], {}, { active: "true" }]) {
|
|
1824
|
+
pi.events.emit("gentle-pi:ask-user-choice:blocked", malformed);
|
|
1825
|
+
}
|
|
1826
|
+
assert.deepEqual(herdrEvents, []);
|
|
1827
|
+
choiceEvents.length = 0;
|
|
1667
1828
|
pi.events.emit("gentle-pi:ask-user-choice:blocked", { active: true });
|
|
1668
1829
|
assert.deepEqual(choiceEvents, [{ active: true }]);
|
|
1669
1830
|
assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
|
|
@@ -1674,11 +1835,11 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1674
1835
|
cwd: process.cwd(),
|
|
1675
1836
|
hasUI: true,
|
|
1676
1837
|
ui: {
|
|
1677
|
-
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); }),
|
|
1838
|
+
confirm: async () => new Promise<boolean>((resolve) => { confirmations.push(resolve); confirmation.observe(); }),
|
|
1678
1839
|
},
|
|
1679
|
-
} as ExtensionContext,
|
|
1840
|
+
} as unknown as ExtensionContext,
|
|
1680
1841
|
);
|
|
1681
|
-
await
|
|
1842
|
+
await confirmation.waitFor(1);
|
|
1682
1843
|
assert.equal(confirmations.length, 1);
|
|
1683
1844
|
assert.deepEqual(herdrEvents, [{ active: true, label: "Choice awaiting input" }]);
|
|
1684
1845
|
|
|
@@ -1686,7 +1847,6 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1686
1847
|
assert.deepEqual(choiceEvents, [{ active: true }, { active: false }]);
|
|
1687
1848
|
assert.deepEqual(herdrEvents, [
|
|
1688
1849
|
{ active: true, label: "Choice awaiting input" },
|
|
1689
|
-
{ active: true, label: "Guarded command confirmation" },
|
|
1690
1850
|
]);
|
|
1691
1851
|
assert.equal(herdrEvents.some((event) => event.active === false), false);
|
|
1692
1852
|
|
|
@@ -1694,7 +1854,6 @@ test("closed choice blockers retain the visible choice label through guarded-con
|
|
|
1694
1854
|
assert.equal(await guardedRequest, undefined);
|
|
1695
1855
|
assert.deepEqual(herdrEvents, [
|
|
1696
1856
|
{ active: true, label: "Choice awaiting input" },
|
|
1697
|
-
{ active: true, label: "Guarded command confirmation" },
|
|
1698
1857
|
{ active: false },
|
|
1699
1858
|
]);
|
|
1700
1859
|
});
|
|
@@ -1715,7 +1874,7 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
|
|
|
1715
1874
|
registerCommand() {},
|
|
1716
1875
|
registerTool() {},
|
|
1717
1876
|
} as unknown as ExtensionAPI;
|
|
1718
|
-
createGentleAiExtension({ nativeReviewCli: null })(pi);
|
|
1877
|
+
createGentleAiExtension({ processEnv: { GENTLE_PI_AGENTS_CHILD: "0" }, nativeReviewCli: null })(pi);
|
|
1719
1878
|
const toolCall = handlers.get("tool_call");
|
|
1720
1879
|
assert.equal(typeof toolCall, "function");
|
|
1721
1880
|
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-permission-headless-"));
|
|
@@ -1728,12 +1887,12 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy
|
|
|
1728
1887
|
cwd,
|
|
1729
1888
|
hasUI: false,
|
|
1730
1889
|
ui: { confirm },
|
|
1731
|
-
} as ExtensionContext), undefined);
|
|
1890
|
+
} as unknown as ExtensionContext), undefined);
|
|
1732
1891
|
assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, {
|
|
1733
1892
|
cwd,
|
|
1734
1893
|
hasUI: false,
|
|
1735
1894
|
ui: { confirm },
|
|
1736
|
-
} as ExtensionContext), {
|
|
1895
|
+
} as unknown as ExtensionContext), {
|
|
1737
1896
|
block: true,
|
|
1738
1897
|
reason: "Gentle AI safety policy requires interactive confirmation before this command.",
|
|
1739
1898
|
});
|
|
@@ -1747,15 +1906,15 @@ test("registered Gentle Review capture tools name the lens they run", () => {
|
|
|
1747
1906
|
const tools = registeredGentleTools();
|
|
1748
1907
|
const binding = (lens: string) => JSON.stringify({ name: "reviewer_result", captureOperation: "review.capture-result", arguments: [], artifactSubject: { lens } });
|
|
1749
1908
|
const single = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: binding("review-risk") }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
|
|
1750
|
-
assert.equal(cardTitle(renderComponent(single)), "
|
|
1909
|
+
assert.equal(cardTitle(renderComponent(single)), "🌹 rdd running · capture · risk");
|
|
1751
1910
|
const bare = tools.get("gentle_review_capture")!.renderCall({ lineageId: "l", collectBinding: "{not json" }, lifecycleTheme, lifecycleContext({ executionStarted: true }));
|
|
1752
|
-
assert.equal(cardTitle(renderComponent(bare)), "
|
|
1911
|
+
assert.equal(cardTitle(renderComponent(bare)), "🌹 rdd running · capture");
|
|
1753
1912
|
const group = tools.get("gentle_review_capture_group")!.renderCall(
|
|
1754
1913
|
{ lineageId: "l", collectBindings: [binding("review-risk"), binding("review-resilience"), binding("review-readability"), binding("review-reliability")] },
|
|
1755
1914
|
lifecycleTheme,
|
|
1756
1915
|
lifecycleContext({ executionStarted: true }),
|
|
1757
1916
|
);
|
|
1758
|
-
assert.equal(cardTitle(renderComponent(group)), "
|
|
1917
|
+
assert.equal(cardTitle(renderComponent(group)), "🌹 rdd running · capture group · risk · resilience · readability · reliability");
|
|
1759
1918
|
});
|
|
1760
1919
|
|
|
1761
1920
|
test("bash tool_call confirms a late guarded npm publish and denies on non-approval", async () => {
|
|
@@ -1788,7 +1947,7 @@ test("bash tool_call confirms a late guarded npm publish and denies on non-appro
|
|
|
1788
1947
|
return false;
|
|
1789
1948
|
},
|
|
1790
1949
|
},
|
|
1791
|
-
} as ExtensionContext;
|
|
1950
|
+
} as unknown as ExtensionContext;
|
|
1792
1951
|
|
|
1793
1952
|
const prefix = "noise ".repeat(80);
|
|
1794
1953
|
const command = `${prefix}npm publish --tag beta`;
|
|
@@ -1840,7 +1999,7 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
|
|
|
1840
1999
|
cwd: process.cwd(),
|
|
1841
2000
|
hasUI: true,
|
|
1842
2001
|
ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) },
|
|
1843
|
-
} as ExtensionContext);
|
|
2002
|
+
} as unknown as ExtensionContext);
|
|
1844
2003
|
assert.deepEqual(result, {
|
|
1845
2004
|
block: true,
|
|
1846
2005
|
reason: "Gentle AI safety policy blocked the command because it was not confirmed.",
|
|
@@ -1864,9 +2023,11 @@ function applyOnce(
|
|
|
1864
2023
|
fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
|
|
1865
2024
|
): void {
|
|
1866
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.
|
|
1867
2028
|
fixture.onInput((panel) => {
|
|
1868
2029
|
visits += 1;
|
|
1869
|
-
panel.handleInput(visits === 1 ? "
|
|
2030
|
+
panel.handleInput(visits === 1 ? "a" : "\x1b");
|
|
1870
2031
|
});
|
|
1871
2032
|
}
|
|
1872
2033
|
|
|
@@ -1926,6 +2087,9 @@ test("applying a profile persists its orchestrator and never leaks the key into
|
|
|
1926
2087
|
worker: { model: "openai/alpha" },
|
|
1927
2088
|
},
|
|
1928
2089
|
});
|
|
2090
|
+
// Every global apply now confirms, so this populated apply approves the
|
|
2091
|
+
// dialog explicitly before running.
|
|
2092
|
+
fixture.onConfirm(async () => true);
|
|
1929
2093
|
applyOnce(fixture);
|
|
1930
2094
|
await fixture.run("gentle:profiles");
|
|
1931
2095
|
|
|
@@ -1951,6 +2115,8 @@ test("applying a profile persists its orchestrator and never leaks the key into
|
|
|
1951
2115
|
{ kind: "model", provider: "nan", id: "glm5.3" },
|
|
1952
2116
|
{ kind: "thinking", level: "max" },
|
|
1953
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/);
|
|
1954
2120
|
});
|
|
1955
2121
|
|
|
1956
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) => {
|
|
@@ -2016,193 +2182,841 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
|
|
|
2016
2182
|
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
|
|
2017
2183
|
});
|
|
2018
2184
|
|
|
2019
|
-
test("applying
|
|
2020
|
-
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2185
|
+
test("applying an empty profile asks for confirmation and aborts when declined", async (t) => {
|
|
2186
|
+
const { fixture, storePath, writeStore, writeSettings, settingsPath } = profilesStoreFixture(t);
|
|
2021
2187
|
writeSettings();
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2025
|
-
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
|
|
2188
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2189
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2026
2190
|
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2027
|
-
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: {
|
|
2028
|
-
writeStore({
|
|
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
|
+
|
|
2029
2205
|
applyOnce(fixture);
|
|
2030
2206
|
await fixture.run("gentle:profiles");
|
|
2031
2207
|
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
assert.
|
|
2036
|
-
assert.
|
|
2037
|
-
assert.match(
|
|
2038
|
-
|
|
2039
|
-
assert.
|
|
2040
|
-
|
|
2041
|
-
assert.
|
|
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");
|
|
2042
2225
|
});
|
|
2043
2226
|
|
|
2044
|
-
test("
|
|
2045
|
-
const { fixture,
|
|
2046
|
-
|
|
2047
|
-
// routing was already materialized, which triggers the rollback path.
|
|
2048
|
-
writeFileSync(settingsPath, "{ not json\n");
|
|
2049
|
-
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2050
|
-
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2051
|
-
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2052
|
-
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
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();
|
|
2053
2230
|
mkdirSync(fixture.configHome, { recursive: true });
|
|
2054
|
-
writeFileSync(fixture.globalPath, `${JSON.stringify({
|
|
2055
|
-
|
|
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`);
|
|
2056
2234
|
writeStore({
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
}, "
|
|
2235
|
+
empty: {},
|
|
2236
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2237
|
+
}, "team");
|
|
2238
|
+
|
|
2239
|
+
fixture.onConfirm(async () => true);
|
|
2240
|
+
|
|
2060
2241
|
applyOnce(fixture);
|
|
2061
2242
|
await fixture.run("gentle:profiles");
|
|
2062
2243
|
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
assert.deepEqual(
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
assert.
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
assert.
|
|
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)");
|
|
2072
2253
|
});
|
|
2073
2254
|
|
|
2074
|
-
test("
|
|
2075
|
-
const { fixture,
|
|
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);
|
|
2076
2257
|
writeSettings();
|
|
2077
2258
|
mkdirSync(fixture.configHome, { recursive: true });
|
|
2078
|
-
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha"
|
|
2259
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
2079
2260
|
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2080
2261
|
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2081
|
-
|
|
2262
|
+
writeStore({
|
|
2263
|
+
orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
|
|
2264
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2265
|
+
}, "team");
|
|
2082
2266
|
const before = {
|
|
2083
2267
|
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2084
2268
|
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2085
|
-
|
|
2269
|
+
store: readFileSync(storePath, "utf8"),
|
|
2086
2270
|
settings: readFileSync(settingsPath, "utf8"),
|
|
2087
2271
|
};
|
|
2088
|
-
writeStore({
|
|
2089
|
-
"a-target": {},
|
|
2090
|
-
"z-active": { worker: { model: "openai/beta" } },
|
|
2091
|
-
}, "z-active");
|
|
2092
2272
|
|
|
2093
|
-
|
|
2094
|
-
fixture.onInput((panel) => {
|
|
2095
|
-
if (firstPanel === undefined) {
|
|
2096
|
-
firstPanel = panel;
|
|
2097
|
-
panel.handleInput("s");
|
|
2098
|
-
panel.handleInput("\x1b");
|
|
2099
|
-
} else {
|
|
2100
|
-
panel.handleInput("\x1b");
|
|
2101
|
-
}
|
|
2102
|
-
});
|
|
2103
|
-
await fixture.run("gentle:profiles");
|
|
2104
|
-
|
|
2105
|
-
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2106
|
-
assert.deepEqual(store.profiles["a-target"], {
|
|
2107
|
-
worker: { model: "openai/alpha", thinking: "minimal" },
|
|
2108
|
-
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2109
|
-
});
|
|
2110
|
-
assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
|
|
2111
|
-
assert.equal(store.active, "z-active");
|
|
2112
|
-
assert.match(fixture.panels[0] ?? "", /enter apply · c create · s snapshot/);
|
|
2113
|
-
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
|
|
2114
|
-
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
|
|
2115
|
-
assert.equal(readFileSync(workerPath, "utf8"), before.worker);
|
|
2116
|
-
assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
|
|
2117
|
-
assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
|
|
2118
|
-
assert.ok(firstPanel);
|
|
2119
|
-
const targetRow = renderComponent(firstPanel!).split("\n");
|
|
2120
|
-
const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
|
|
2121
|
-
assert.ok(targetIndex >= 0, "selected profile remains in the list");
|
|
2122
|
-
assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
|
|
2123
|
-
assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
|
|
2124
|
-
});
|
|
2273
|
+
fixture.onConfirm(async () => false);
|
|
2125
2274
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
|
|
2129
|
-
// Rows: "Set all agents", the two provider review roles, then "worker".
|
|
2130
|
-
for (let step = 0; step < 3; step += 1) panel.handleInput("j");
|
|
2131
|
-
assert.match(renderComponent(panel), /▸ worker/);
|
|
2132
|
-
panel.handleInput("\r");
|
|
2133
|
-
for (const character of "alpha") panel.handleInput(character);
|
|
2134
|
-
panel.handleInput("\r");
|
|
2135
|
-
panel.handleInput("u");
|
|
2136
|
-
}
|
|
2275
|
+
applyOnce(fixture);
|
|
2276
|
+
await fixture.run("gentle:profiles");
|
|
2137
2277
|
|
|
2138
|
-
|
|
2139
|
-
const
|
|
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);
|
|
2140
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`);
|
|
2141
2306
|
writeStore({
|
|
2142
|
-
|
|
2143
|
-
|
|
2307
|
+
orchOnly: { orchestrator: { model: "nan/glm5.3", thinking: "high" } },
|
|
2308
|
+
team: { worker: { model: "openai/alpha" } },
|
|
2144
2309
|
}, "team");
|
|
2145
|
-
fixture.onInput((panel) => {
|
|
2146
|
-
assert.match(renderComponent(panel), /Current profile: team/);
|
|
2147
|
-
assert.match(renderComponent(panel), /u update profile/);
|
|
2148
|
-
pickWorkerModelThenUpdateProfile(panel);
|
|
2149
|
-
});
|
|
2150
|
-
await fixture.run("gentle:models");
|
|
2151
2310
|
|
|
2152
|
-
|
|
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
|
+
|
|
2153
2320
|
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2154
|
-
assert.
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
assert.
|
|
2159
|
-
assert.equal(
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
);
|
|
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");
|
|
2169
2335
|
});
|
|
2170
2336
|
|
|
2171
|
-
test("
|
|
2172
|
-
const { fixture, storePath, writeStore, writeSettings,
|
|
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);
|
|
2173
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");
|
|
2174
2348
|
writeStore({
|
|
2175
|
-
team: {
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
|
|
2181
|
-
pickWorkerModelThenUpdateProfile(panel);
|
|
2349
|
+
team: {
|
|
2350
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
2351
|
+
worker: { model: "openai/beta", thinking: "max" },
|
|
2352
|
+
tracer: { model: "openai/alpha" },
|
|
2353
|
+
},
|
|
2182
2354
|
});
|
|
2183
|
-
|
|
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
|
+
};
|
|
2184
2361
|
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
assert.equal(
|
|
2193
|
-
assert.
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
);
|
|
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");
|
|
2197
2381
|
});
|
|
2198
2382
|
|
|
2199
|
-
test("
|
|
2200
|
-
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
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);
|
|
2201
2385
|
writeSettings();
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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
|
+
|
|
2803
|
+
test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
|
|
2804
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2805
|
+
writeSettings();
|
|
2806
|
+
// Routing materialized earlier (a previous profile, /gentle:models, or a
|
|
2807
|
+
// migration) for an agent the new profile does not mention.
|
|
2808
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2809
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
|
|
2810
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2811
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2812
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
2813
|
+
applyOnce(fixture);
|
|
2814
|
+
await fixture.run("gentle:profiles");
|
|
2815
|
+
|
|
2816
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2817
|
+
assert.deepEqual(profiles.model_profiles, { worker: { model: "openai/alpha" } }, "omitted agents lose their materialized route");
|
|
2818
|
+
const helper = readFileSync(helperPath, "utf8");
|
|
2819
|
+
assert.doesNotMatch(helper, /^model:/m);
|
|
2820
|
+
assert.doesNotMatch(helper, /^thinking:/m);
|
|
2821
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
|
|
2822
|
+
// models.json stays the profile itself, not a padded copy.
|
|
2823
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2824
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
2825
|
+
assert.match(applied, /applied profile "team"/);
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
test("a failed apply restores the previous profile's routing with the same replacement semantics", async (t) => {
|
|
2829
|
+
const { fixture, settingsPath, writeStore } = profilesStoreFixture(t);
|
|
2830
|
+
// An unreadable settings.json makes the orchestrator write fail after the
|
|
2831
|
+
// routing was already materialized, which triggers the rollback path.
|
|
2832
|
+
writeFileSync(settingsPath, "{ not json\n");
|
|
2833
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
2834
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
2835
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2836
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
2837
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2838
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ helper: { model: "openai/beta" } }, null, 2)}\n`);
|
|
2839
|
+
// "team" is listed first, so Enter applies it while "old" stays the active one.
|
|
2840
|
+
writeStore({
|
|
2841
|
+
team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } },
|
|
2842
|
+
old: { helper: { model: "openai/beta" } },
|
|
2843
|
+
}, "old");
|
|
2844
|
+
applyOnce(fixture);
|
|
2845
|
+
await fixture.run("gentle:profiles");
|
|
2846
|
+
|
|
2847
|
+
const warning = fixture.notifications.find((entry) => /could not apply profile "team"/.test(entry.message));
|
|
2848
|
+
assert.ok(warning, "the failed apply is reported");
|
|
2849
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { helper: { model: "openai/beta" } });
|
|
2850
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
2851
|
+
assert.deepEqual(profiles.model_profiles, { helper: { model: "openai/beta" } }, "the previous routing is materialized again and the failed profile's routes are cleared");
|
|
2852
|
+
assert.match(readFileSync(helperPath, "utf8"), /model: openai\/beta/);
|
|
2853
|
+
assert.doesNotMatch(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /^model:/m);
|
|
2854
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2855
|
+
assert.equal(store.active, "old");
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
|
|
2859
|
+
const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2860
|
+
writeSettings();
|
|
2861
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
2862
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha", thinking: "minimal" } }, null, 2)}\n`);
|
|
2863
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
2864
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { worker: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
2865
|
+
const workerPath = join(fixture.root, ".pi", "agents", "worker.md");
|
|
2866
|
+
const before = {
|
|
2867
|
+
models: readFileSync(fixture.globalPath, "utf8"),
|
|
2868
|
+
subagents: readFileSync(subagentsPath, "utf8"),
|
|
2869
|
+
worker: readFileSync(workerPath, "utf8"),
|
|
2870
|
+
settings: readFileSync(settingsPath, "utf8"),
|
|
2871
|
+
};
|
|
2872
|
+
writeStore({
|
|
2873
|
+
"a-target": {},
|
|
2874
|
+
"z-active": { worker: { model: "openai/beta" } },
|
|
2875
|
+
}, "z-active");
|
|
2876
|
+
|
|
2877
|
+
let firstPanel: RoutingConsumerPanel | undefined;
|
|
2878
|
+
fixture.onInput((panel) => {
|
|
2879
|
+
if (firstPanel === undefined) {
|
|
2880
|
+
firstPanel = panel;
|
|
2881
|
+
panel.handleInput("s");
|
|
2882
|
+
panel.handleInput("\x1b");
|
|
2883
|
+
} else {
|
|
2884
|
+
panel.handleInput("\x1b");
|
|
2885
|
+
}
|
|
2886
|
+
});
|
|
2887
|
+
await fixture.run("gentle:profiles");
|
|
2888
|
+
|
|
2889
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
2890
|
+
assert.deepEqual(store.profiles["a-target"], {
|
|
2891
|
+
worker: { model: "openai/alpha", thinking: "minimal" },
|
|
2892
|
+
orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
|
|
2893
|
+
});
|
|
2894
|
+
assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
|
|
2895
|
+
assert.equal(store.active, "z-active");
|
|
2896
|
+
assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
|
|
2897
|
+
assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
|
|
2898
|
+
assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
|
|
2899
|
+
assert.equal(readFileSync(workerPath, "utf8"), before.worker);
|
|
2900
|
+
assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
|
|
2901
|
+
assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
|
|
2902
|
+
assert.ok(firstPanel);
|
|
2903
|
+
const targetRow = renderComponent(firstPanel!).split("\n");
|
|
2904
|
+
const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
|
|
2905
|
+
assert.ok(targetIndex >= 0, "selected profile remains in the list");
|
|
2906
|
+
assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
|
|
2907
|
+
assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
|
|
2908
|
+
});
|
|
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
|
+
|
|
2936
|
+
// /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
|
|
2937
|
+
// by the snapshot `/gentle:profiles` performs with `s` on the current profile.
|
|
2938
|
+
function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
|
|
2939
|
+
// Rows: "Set all agents", the two provider review roles, then "worker".
|
|
2940
|
+
for (let step = 0; step < 3; step += 1) panel.handleInput("j");
|
|
2941
|
+
assert.match(renderComponent(panel), /▸ worker/);
|
|
2942
|
+
panel.handleInput("\r");
|
|
2943
|
+
for (const character of "alpha") panel.handleInput(character);
|
|
2944
|
+
panel.handleInput("\r");
|
|
2945
|
+
panel.handleInput("u");
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
test("u saves global routing and captures live session orchestrator instead of defaults", async (t) => {
|
|
2949
|
+
const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
2950
|
+
writeSettings();
|
|
2951
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
2952
|
+
fixture.setLiveModel("openai", "alpha", "low");
|
|
2953
|
+
writeStore({
|
|
2954
|
+
team: { worker: { model: "openai/beta" } },
|
|
2955
|
+
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
2956
|
+
}, "team");
|
|
2957
|
+
fixture.onInput((panel) => {
|
|
2958
|
+
assert.match(renderComponent(panel), /Current profile: team/);
|
|
2959
|
+
assert.match(renderComponent(panel), /u capture session in "team"/);
|
|
2960
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2961
|
+
});
|
|
2962
|
+
await fixture.run("gentle:models");
|
|
2963
|
+
|
|
2964
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
2965
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
2966
|
+
assert.deepEqual(store.profiles.team, {
|
|
2967
|
+
worker: { model: "openai/alpha" },
|
|
2968
|
+
orchestrator: { model: "openai/alpha", thinking: "low" },
|
|
2969
|
+
});
|
|
2970
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "global defaults remain unchanged");
|
|
2971
|
+
assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
|
|
2972
|
+
assert.equal(store.active, "team");
|
|
2973
|
+
assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
|
|
2974
|
+
assert.ok(
|
|
2975
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
|
|
2976
|
+
"the global save is still reported",
|
|
2977
|
+
);
|
|
2978
|
+
assert.ok(
|
|
2979
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
|
|
2980
|
+
`profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
2981
|
+
);
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
test("u updates the pinned profile instead of the active one inside a pinned repository", async (t) => {
|
|
2985
|
+
const { fixture, storePath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
|
|
2986
|
+
writeSettings();
|
|
2987
|
+
fixture.setLiveModel("openai", "beta", "medium");
|
|
2988
|
+
writeStore({
|
|
2989
|
+
team: { worker: { model: "openai/beta" } },
|
|
2990
|
+
other: { worker: { model: "openai/beta", thinking: "high" } },
|
|
2991
|
+
}, "team");
|
|
2992
|
+
writePin(localPinPath, "other");
|
|
2993
|
+
fixture.onInput((panel) => {
|
|
2994
|
+
assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
|
|
2995
|
+
pickWorkerModelThenUpdateProfile(panel);
|
|
2996
|
+
});
|
|
2997
|
+
await fixture.run("gentle:models");
|
|
2998
|
+
|
|
2999
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
3000
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3001
|
+
assert.deepEqual(store.profiles.other, {
|
|
3002
|
+
worker: { model: "openai/alpha" },
|
|
3003
|
+
orchestrator: { model: "openai/beta", thinking: "medium" },
|
|
3004
|
+
});
|
|
3005
|
+
assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
|
|
3006
|
+
assert.equal(store.active, "team");
|
|
3007
|
+
assert.ok(
|
|
3008
|
+
fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
|
|
3009
|
+
`pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
|
|
3010
|
+
);
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
test("u keeps the global save and reports when no profile is current", async (t) => {
|
|
3014
|
+
const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
3015
|
+
writeSettings();
|
|
3016
|
+
writeStore({ team: { worker: { model: "openai/beta" } } });
|
|
3017
|
+
const before = readFileSync(storePath, "utf8");
|
|
3018
|
+
fixture.onInput((panel) => {
|
|
3019
|
+
assert.match(renderComponent(panel), /Current profile: none/);
|
|
2206
3020
|
pickWorkerModelThenUpdateProfile(panel);
|
|
2207
3021
|
});
|
|
2208
3022
|
await fixture.run("gentle:models");
|
|
@@ -2393,6 +3207,116 @@ test("j and k scroll the detail pane one line at a time, like the agents view",
|
|
|
2393
3207
|
assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
|
|
2394
3208
|
});
|
|
2395
3209
|
|
|
3210
|
+
// The name prompt behind c, d, and r: the Pi host starts the field empty and
|
|
3211
|
+
// ignores the placeholder, and every outcome must show in the reopened panel,
|
|
3212
|
+
// because the fullscreen overlay hides notifications until it closes.
|
|
3213
|
+
async function runProfilesNameAction(
|
|
3214
|
+
fixture: ReturnType<typeof routingConsumerFixture>,
|
|
3215
|
+
keys: string | string[],
|
|
3216
|
+
...answers: Array<string | undefined>
|
|
3217
|
+
): Promise<string> {
|
|
3218
|
+
fixture.answerInputs(...answers);
|
|
3219
|
+
let visits = 0;
|
|
3220
|
+
fixture.onInput((panel) => {
|
|
3221
|
+
visits += 1;
|
|
3222
|
+
if (visits > 1) return panel.handleInput("\x1b");
|
|
3223
|
+
for (const key of [keys].flat()) panel.handleInput(key);
|
|
3224
|
+
});
|
|
3225
|
+
await fixture.run("gentle:profiles");
|
|
3226
|
+
assert.equal(fixture.panels.length, 2, "the panel reopens once after the action");
|
|
3227
|
+
return fixture.panels[1];
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/** The selected profile, read from the detail pane title of a rendered panel. */
|
|
3231
|
+
function selectedProfileTitle(rendered: string): string {
|
|
3232
|
+
return (rendered.split("\n")[1]?.split("│")[2] ?? "").replace("(active)", "").trim();
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
function profilesPanelFooter(rendered: string): string {
|
|
3236
|
+
return rendered.split("\n").at(-2) ?? "";
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
test("c creates the named profile, selects it, and reports it in the reopened panel", async (t) => {
|
|
3240
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3241
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3242
|
+
const reopened = await runProfilesNameAction(fixture, "c", " deep-work ");
|
|
3243
|
+
|
|
3244
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3245
|
+
assert.deepEqual(Object.keys(store.profiles), ["team", "deep-work"]);
|
|
3246
|
+
assert.equal(selectedProfileTitle(reopened), "deep-work");
|
|
3247
|
+
assert.match(profilesPanelFooter(reopened), /Profile "deep-work" created\./);
|
|
3248
|
+
});
|
|
3249
|
+
|
|
3250
|
+
test("c with an empty name creates nothing and says so in the reopened panel", async (t) => {
|
|
3251
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3252
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3253
|
+
const before = readFileSync(storePath, "utf8");
|
|
3254
|
+
const reopened = await runProfilesNameAction(fixture, "c", " ");
|
|
3255
|
+
|
|
3256
|
+
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3257
|
+
assert.equal(selectedProfileTitle(reopened), "team");
|
|
3258
|
+
assert.match(profilesPanelFooter(reopened), /No profile created: no name entered\./);
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
test("d with an empty name duplicates to the suggested <name>-copy and selects it", async (t) => {
|
|
3262
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3263
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3264
|
+
const reopened = await runProfilesNameAction(fixture, "d", "");
|
|
3265
|
+
|
|
3266
|
+
// The host never shows the placeholder, so the suggestion lives in the title.
|
|
3267
|
+
assert.match(fixture.inputPrompts[0]?.title ?? "", /Duplicate profile "team" as \(empty = team-copy\)/);
|
|
3268
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3269
|
+
assert.deepEqual(store.profiles["team-copy"], { worker: { model: "openai/alpha" } });
|
|
3270
|
+
assert.equal(selectedProfileTitle(reopened), "team-copy");
|
|
3271
|
+
assert.match(profilesPanelFooter(reopened), /Profile "team" duplicated as "team-copy"\./);
|
|
3272
|
+
});
|
|
3273
|
+
|
|
3274
|
+
test("d onto an existing name writes nothing and shows the conflict in the reopened panel", async (t) => {
|
|
3275
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3276
|
+
writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
|
|
3277
|
+
const before = readFileSync(storePath, "utf8");
|
|
3278
|
+
const reopened = await runProfilesNameAction(fixture, "d", "other");
|
|
3279
|
+
|
|
3280
|
+
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3281
|
+
assert.equal(selectedProfileTitle(reopened), "team");
|
|
3282
|
+
assert.match(profilesPanelFooter(reopened), /Profile not duplicated: Profile already exists: other\./);
|
|
3283
|
+
});
|
|
3284
|
+
|
|
3285
|
+
test("r with an empty name leaves the profile unchanged and says so in the reopened panel", async (t) => {
|
|
3286
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3287
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3288
|
+
const before = readFileSync(storePath, "utf8");
|
|
3289
|
+
const reopened = await runProfilesNameAction(fixture, "r", "");
|
|
3290
|
+
|
|
3291
|
+
assert.match(fixture.inputPrompts[0]?.title ?? "", /Rename profile "team" to \(empty = keep team\)/);
|
|
3292
|
+
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3293
|
+
assert.equal(selectedProfileTitle(reopened), "team");
|
|
3294
|
+
assert.match(profilesPanelFooter(reopened), /Profile "team" unchanged: no new name entered\./);
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3297
|
+
test("r renames the profile, keeps it selected under the new name, and reports it", async (t) => {
|
|
3298
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3299
|
+
writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
|
|
3300
|
+
// Rename the second row, so the reopened selection cannot land on it by default.
|
|
3301
|
+
const reopened = await runProfilesNameAction(fixture, ["\x1b[B", "r"], "focus");
|
|
3302
|
+
|
|
3303
|
+
const store = JSON.parse(readFileSync(storePath, "utf8"));
|
|
3304
|
+
assert.deepEqual(Object.keys(store.profiles), ["team", "focus"]);
|
|
3305
|
+
assert.equal(store.active, "team");
|
|
3306
|
+
assert.equal(selectedProfileTitle(reopened), "focus");
|
|
3307
|
+
assert.match(profilesPanelFooter(reopened), /Profile "other" renamed to "focus"\./);
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
test("escape on the name prompt cancels without writing and says so in the reopened panel", async (t) => {
|
|
3311
|
+
const { fixture, storePath, writeStore } = profilesStoreFixture(t);
|
|
3312
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
|
|
3313
|
+
const before = readFileSync(storePath, "utf8");
|
|
3314
|
+
const reopened = await runProfilesNameAction(fixture, "d", undefined);
|
|
3315
|
+
|
|
3316
|
+
assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
|
|
3317
|
+
assert.match(profilesPanelFooter(reopened), /Duplicate cancelled\./);
|
|
3318
|
+
});
|
|
3319
|
+
|
|
2396
3320
|
// The pin is the per-repository layer of the profiles command: `p` writes the
|
|
2397
3321
|
// clone-scoped pin inside the Git common directory, `P` writes the committable
|
|
2398
3322
|
// per-worktree declaration. Neither writes routing.
|
|
@@ -2577,6 +3501,7 @@ test("applying a profile in a pinned repository re-pins the clone and writes no
|
|
|
2577
3501
|
assert.equal(readFileSync(fixture.globalPath, "utf8"), modelsBefore, "no global routing was written");
|
|
2578
3502
|
assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "no orchestrator was written");
|
|
2579
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");
|
|
2580
3505
|
assert.match(fixture.notifications.at(-1)?.message ?? "", /repo-scoped/);
|
|
2581
3506
|
});
|
|
2582
3507
|
|
|
@@ -2772,3 +3697,269 @@ test("switchLiveOrchestrator returns note when setModel fails", async () => {
|
|
|
2772
3697
|
const result = await __testing.switchLiveOrchestrator(ctx, live, entry);
|
|
2773
3698
|
assert.equal(result, "\nno authentication is configured for openai; this session keeps its current model.");
|
|
2774
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
|
+
});
|