gentle-pi 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +29 -25
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase.ts +1 -1
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +7 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +79 -32
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +52 -1
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +6 -5
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +5 -4
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-risk-assessment.test.ts +109 -33
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +27 -1
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -12,20 +12,25 @@ import { SessionChanges, type SessionChangeEvidence } from "../lib/session-chang
|
|
|
12
12
|
import test, { after, afterEach, before, mock } from "node:test";
|
|
13
13
|
import type { TestContext } from "node:test";
|
|
14
14
|
import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
|
15
16
|
import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
16
17
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
18
|
+
import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection } from "../lib/child-package-injection.ts";
|
|
17
19
|
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, agentResultPreview, answerThroughUi, childContextExtensionPaths, completionText, createDefaultSessionTransport, legacySubagentsInstalled, PARENT_WAKE_GRACE_MS, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
|
|
18
20
|
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
|
|
19
21
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
|
|
20
22
|
import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
|
|
21
23
|
import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
|
|
22
|
-
import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
24
|
+
import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
23
25
|
import { NativePointerScope } from "../lib/native-pointer-region.ts";
|
|
24
|
-
import { PresenceCursor, PresencePublisher, listPresence, readActivity } from "../lib/orchestrator-presence.ts";
|
|
26
|
+
import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
|
|
27
|
+
import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
|
|
25
28
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
26
29
|
import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
27
|
-
import { AgentRunner } from "../lib/agents-runner.ts";
|
|
30
|
+
import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
|
|
28
31
|
import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
|
|
32
|
+
import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
33
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
29
34
|
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
30
35
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
31
36
|
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
@@ -48,6 +53,10 @@ interface Registered {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
56
|
+
// Session shutdown persists finished subagent history asynchronously, so a
|
|
57
|
+
// late write can race a teardown removal; Node retries ENOTEMPTY/EBUSY here
|
|
58
|
+
// instead of failing the test (macOS showed it, #1840).
|
|
59
|
+
const TEST_DIR_REMOVAL = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
|
|
51
60
|
const fakeTui = { requestRender() {} };
|
|
52
61
|
const inertSessionTransport: SessionTransportFactory = {
|
|
53
62
|
createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
|
|
@@ -84,6 +93,9 @@ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
|
|
|
84
93
|
const activeSessionTeardowns = new Set<() => Promise<void>>();
|
|
85
94
|
const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
|
|
86
95
|
afterEach(stopActiveSessions);
|
|
96
|
+
// Every fixture session is "s1": the inherited-profile freeze is process state
|
|
97
|
+
// keyed by session id, so each test starts from an unfrozen session.
|
|
98
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
87
99
|
// subagent_run's default mode now reads the background-subagents policy
|
|
88
100
|
// in-process (gentle-pi#background-subagents-default-mode), which falls
|
|
89
101
|
// back to the real ~/.pi/gentle-ai/background-subagents.json when
|
|
@@ -98,7 +110,7 @@ after(async () => {
|
|
|
98
110
|
finally {
|
|
99
111
|
if (previousGentlePiConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
100
112
|
else process.env.GENTLE_PI_CONFIG_HOME = previousGentlePiConfigHome;
|
|
101
|
-
rmSync(root,
|
|
113
|
+
rmSync(root, TEST_DIR_REMOVAL);
|
|
102
114
|
}
|
|
103
115
|
});
|
|
104
116
|
const home = join(root, "home");
|
|
@@ -110,19 +122,22 @@ mkdirSync(nonGitCwd, { recursive: true });
|
|
|
110
122
|
writeFileSync(join(home, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\ntools: [read, grep]\n---\nYou map things.");
|
|
111
123
|
writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } }));
|
|
112
124
|
|
|
113
|
-
|
|
125
|
+
// Existing prompt-lifecycle regressions exercise the bridge route by default.
|
|
126
|
+
function fakePi(initialProvider: string = "claude-bridge") {
|
|
127
|
+
const transformers: Array<Parameters<ExtensionAPI["registerMarkdownTransformer"]>[0]> = [];
|
|
114
128
|
const handlers = new Map<string, Handler[]>();
|
|
115
129
|
const tools = new Map<string, Registered>();
|
|
116
130
|
const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
|
|
117
131
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
|
|
118
132
|
const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
|
|
119
|
-
//
|
|
120
|
-
// interleaving of custom messages and wake-ups so ordering stays provable.
|
|
133
|
+
// `delivery` records custom messages and user wakes in dispatch order.
|
|
121
134
|
const userMessages: Array<{ content: unknown; options: Record<string, unknown> | undefined }> = [];
|
|
122
135
|
const delivery: string[] = [];
|
|
123
136
|
// A live idle flag shared by every context fired through this host, like
|
|
124
137
|
// Pi's ctx.isIdle(): busy from agent_start until agent_settled. Tests can
|
|
125
138
|
// force it to simulate compaction or other non-run busy states.
|
|
139
|
+
let selectedProvider: string | undefined = initialProvider;
|
|
140
|
+
const setProvider = (provider: string | undefined) => { selectedProvider = provider; };
|
|
126
141
|
let parentIdle = true;
|
|
127
142
|
const setIdle = (idle: boolean) => { parentIdle = idle; };
|
|
128
143
|
const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
|
|
@@ -132,6 +147,7 @@ function fakePi() {
|
|
|
132
147
|
const listeners = new Map<string, Set<(data: unknown) => void>>();
|
|
133
148
|
const pi = {
|
|
134
149
|
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
|
150
|
+
registerMarkdownTransformer: (transformer: Parameters<ExtensionAPI["registerMarkdownTransformer"]>[0]) => transformers.push(transformer),
|
|
135
151
|
events: {
|
|
136
152
|
emit: (name: string, data: unknown) => { events.push({ name, data }); for (const listener of listeners.get(name) ?? []) listener(data); },
|
|
137
153
|
on: (name: string, listener: (data: unknown) => void) => {
|
|
@@ -157,6 +173,10 @@ function fakePi() {
|
|
|
157
173
|
const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
|
|
158
174
|
const results: unknown[] = [];
|
|
159
175
|
if (!("isIdle" in ctx)) Object.assign(ctx, { isIdle: () => parentIdle });
|
|
176
|
+
if (!("model" in ctx)) Object.defineProperty(ctx, "model", {
|
|
177
|
+
configurable: true,
|
|
178
|
+
get: () => selectedProvider === undefined ? undefined : { provider: selectedProvider },
|
|
179
|
+
});
|
|
160
180
|
if (event === "agent_start") parentIdle = false;
|
|
161
181
|
else if (event === "agent_settled") parentIdle = true;
|
|
162
182
|
try {
|
|
@@ -172,7 +192,7 @@ function fakePi() {
|
|
|
172
192
|
}
|
|
173
193
|
}
|
|
174
194
|
};
|
|
175
|
-
return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, renderers, entryRenderers, entries, events, listeners };
|
|
195
|
+
return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, setProvider, renderers, entryRenderers, entries, events, listeners, transformers };
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise<boolean> = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise<string | undefined> = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }, selectResult: (title: string, options: string[]) => Promise<string | undefined> = async (_title, options) => options[0]) {
|
|
@@ -185,7 +205,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
185
205
|
cwd,
|
|
186
206
|
hasUI: true,
|
|
187
207
|
mode: "tui",
|
|
188
|
-
sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [] },
|
|
208
|
+
sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] },
|
|
189
209
|
ui: {
|
|
190
210
|
notify: (message: string) => dialogs.push(`notify:${message}`),
|
|
191
211
|
custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) =>
|
|
@@ -224,6 +244,68 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
224
244
|
return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
|
|
225
245
|
}
|
|
226
246
|
|
|
247
|
+
for (const scenario of ["background", "task", "append-failure", "overflow", "bytes", "replacement", "task-replacement", "unsafe-id", "no-transport"] as const) {
|
|
248
|
+
test(`explicit run work publication: ${scenario}`, async t => {
|
|
249
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
250
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
251
|
+
await h.fire("session_start", ctx);
|
|
252
|
+
const initial = { objective: "Keep objective", work: { area: "Owner", tasks: { historical: { area: "Old" } } as Record<string, { area: string }> } };
|
|
253
|
+
if (scenario === "overflow") for (let i = 0; i < 7; i++) initial.work.tasks[`old-${i}`] = { area: "Old" };
|
|
254
|
+
if (scenario === "bytes") initial.objective = "x".repeat(1980);
|
|
255
|
+
await h.tools.get("orchestrator_session_id")!.execute("publish", { state: initial }, undefined, undefined, ctx);
|
|
256
|
+
const before = h.entries.length;
|
|
257
|
+
if (scenario === "append-failure") t.mock.method(h.pi, "appendEntry", () => { throw new Error("private credential detail"); });
|
|
258
|
+
let allocated: TaskRecord;
|
|
259
|
+
const run = t.mock.method(AgentRunner.prototype, "run", request => {
|
|
260
|
+
assert.equal(Object.hasOwn(request, "work"), false);
|
|
261
|
+
assert.equal(request.prompt, "Map");
|
|
262
|
+
allocated = { id: scenario === "unsafe-id" ? "constructor" : "actual-allocated-id", agent: "explore", label: "Map", mode: request.mode,
|
|
263
|
+
status: TASK_STATUS.COMPLETED, cwd, prompt: request.prompt, parentSessionId: request.parentSessionId,
|
|
264
|
+
createdAt: 1, startedAt: 1, endedAt: 2, model: "fixture", thinking: undefined,
|
|
265
|
+
sessionPath: null, error: null, result: "Done", lastStep: "Done", lastActivityAt: 2,
|
|
266
|
+
turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
|
|
267
|
+
if (scenario === "replacement") ctx.sessionManager = { ...ctx.sessionManager };
|
|
268
|
+
return allocated;
|
|
269
|
+
});
|
|
270
|
+
t.mock.method(AgentRunner.prototype, "waitForQuery", async () => undefined);
|
|
271
|
+
t.mock.method(AgentRunner.prototype, "waitFor", async () => {
|
|
272
|
+
if (scenario === "task-replacement") ctx.sessionManager = { ...ctx.sessionManager };
|
|
273
|
+
return allocated;
|
|
274
|
+
});
|
|
275
|
+
if (scenario === "no-transport") await h.fire("session_shutdown", ctx);
|
|
276
|
+
const result = await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map",
|
|
277
|
+
mode: scenario === "task" || scenario === "task-replacement" ? "task" : "background", work: { area: "Auth" } }, undefined, undefined, ctx);
|
|
278
|
+
assert.equal(run.mock.callCount(), 1);
|
|
279
|
+
assert.equal((result.details.gentleAgents as { taskId: string }).taskId, allocated!.id);
|
|
280
|
+
assert.equal(Object.hasOwn(allocated!, "work"), false);
|
|
281
|
+
const success = scenario === "background" || scenario === "task";
|
|
282
|
+
assert.equal((result.details.workPublication as { status: string }).status, success ? "recorded" : "unavailable");
|
|
283
|
+
assert.equal(h.entries.length, before + (success || scenario === "task-replacement" ? 1 : 0));
|
|
284
|
+
assert.doesNotMatch(result.content[0].text, /private credential/);
|
|
285
|
+
if (success) {
|
|
286
|
+
const state = (h.entries.at(-1)!.data as { state: typeof initial }).state;
|
|
287
|
+
assert.equal(state.objective, "Keep objective");
|
|
288
|
+
assert.deepEqual(state.work.tasks, { historical: { area: "Old" }, "actual-allocated-id": { area: "Auth" } });
|
|
289
|
+
const unclassified = await h.tools.get("subagent_run")!.execute("plain", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
290
|
+
assert.equal(unclassified.details.workPublication, undefined);
|
|
291
|
+
assert.equal(h.entries.length, before + 1);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
test("invalid run work rejects before foreign consent or allocation", async t => {
|
|
297
|
+
const h = fakePi(), runtime = deps(), { ctx, dialogs } = fakeContext();
|
|
298
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
299
|
+
await h.fire("session_start", ctx);
|
|
300
|
+
const run = t.mock.method(AgentRunner.prototype, "run", () => { throw new Error("allocation reached"); });
|
|
301
|
+
for (const work of [{}, { tasks: { guessed: { area: "Auth" } } }, { area: "bad\n" }]) {
|
|
302
|
+
await assert.rejects(h.tools.get("subagent_run")!.execute("invalid", { agent: "explore", task: "Map",
|
|
303
|
+
repository_root: "/foreign", work }, undefined, undefined, ctx), /invalid-published-state/);
|
|
304
|
+
}
|
|
305
|
+
assert.equal(run.mock.callCount(), 0);
|
|
306
|
+
assert.deepEqual(dialogs, []);
|
|
307
|
+
});
|
|
308
|
+
|
|
227
309
|
// Records deps.schedule calls so a test fires exactly the timers it means to;
|
|
228
310
|
// unrelated runner timers stay pending.
|
|
229
311
|
function recordTimers(target: Partial<AgentsDeps>) {
|
|
@@ -236,6 +318,14 @@ function recordTimers(target: Partial<AgentsDeps>) {
|
|
|
236
318
|
const due = (ms: number) => pending.filter((timer) => timer.ms === ms && !timer.cancelled);
|
|
237
319
|
return {
|
|
238
320
|
pending: (ms: number) => due(ms).length,
|
|
321
|
+
// Capture a known newly armed timer before other same-duration timers
|
|
322
|
+
// (for example child query expiry) are added.
|
|
323
|
+
takeLast: (ms: number) => {
|
|
324
|
+
const timer = due(ms).at(-1);
|
|
325
|
+
assert.ok(timer, `expected a pending ${ms}ms timer`);
|
|
326
|
+
pending.splice(pending.indexOf(timer), 1);
|
|
327
|
+
return () => { if (!timer.cancelled) timer.fn(); };
|
|
328
|
+
},
|
|
239
329
|
run: (ms: number) => {
|
|
240
330
|
const timers = due(ms);
|
|
241
331
|
for (const timer of timers) {
|
|
@@ -267,7 +357,9 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
267
357
|
pi: { command: "pi", args: [] },
|
|
268
358
|
home,
|
|
269
359
|
resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
|
|
270
|
-
|
|
360
|
+
// The launch reads the global active profile from the config home; keep it
|
|
361
|
+
// on the scratch directory instead of the developer's own profiles.json.
|
|
362
|
+
env: { PATH: "/bin", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME },
|
|
271
363
|
sessionTransport: inertSessionTransport,
|
|
272
364
|
},
|
|
273
365
|
};
|
|
@@ -341,15 +433,17 @@ test("cache warming follows actual Gentle Agents ownership and completion lifecy
|
|
|
341
433
|
await decide(undefined);
|
|
342
434
|
});
|
|
343
435
|
|
|
344
|
-
|
|
436
|
+
// `pi -p` and `pi --mode json` share pi's one-shot runner: it disposes the
|
|
437
|
+
// runtime once the prompt returns, so neither can receive a background result.
|
|
438
|
+
const SINGLE_SHOT_BACKGROUND_ERROR = "Background subagents are unavailable in single-shot modes: pi -p and pi --mode json exit before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
|
|
345
439
|
|
|
346
|
-
for (const continuation of [false, true]) {
|
|
347
|
-
test(
|
|
440
|
+
for (const hostMode of ["print", "json"] as const) for (const continuation of [false, true]) {
|
|
441
|
+
test(`${hostMode} mode rejects background ${continuation ? "continuation" : "launch"} before allocating a task`, async (t) => {
|
|
348
442
|
const h = fakePi();
|
|
349
443
|
const runtime = deps();
|
|
350
444
|
gentleAgents(h.pi, {}, runtime.deps);
|
|
351
445
|
const { ctx } = fakeContext();
|
|
352
|
-
Object.assign(ctx, { mode:
|
|
446
|
+
Object.assign(ctx, { mode: hostMode, hasUI: false });
|
|
353
447
|
await h.fire("session_start", ctx);
|
|
354
448
|
let taskId: string | undefined;
|
|
355
449
|
if (continuation) {
|
|
@@ -368,7 +462,7 @@ for (const continuation of [false, true]) {
|
|
|
368
462
|
const tool = h.tools.get(continuation ? "subagent_continue" : "subagent_run")!;
|
|
369
463
|
await assert.rejects(tool.execute("denied", continuation
|
|
370
464
|
? { task_id: taskId, prompt: "Follow up", mode: "background" }
|
|
371
|
-
: { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message:
|
|
465
|
+
: { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message: SINGLE_SHOT_BACKGROUND_ERROR });
|
|
372
466
|
await tick();
|
|
373
467
|
assert.equal(run.mock.callCount(), 0, "rejection must precede runner task ID allocation");
|
|
374
468
|
assert.equal(runtime.spawned.length, spawnedBefore, "no child spawned");
|
|
@@ -378,8 +472,9 @@ for (const continuation of [false, true]) {
|
|
|
378
472
|
});
|
|
379
473
|
}
|
|
380
474
|
|
|
381
|
-
for (const mode of ["print", "tui", "rpc"] as const) {
|
|
382
|
-
|
|
475
|
+
for (const mode of ["print", "json", "tui", "rpc"] as const) {
|
|
476
|
+
const singleShot = mode === "print" || mode === "json";
|
|
477
|
+
test(`${mode} preserves ${singleShot ? "bounded task" : "background"} execution`, async () => {
|
|
383
478
|
const h = fakePi();
|
|
384
479
|
const runtime = deps();
|
|
385
480
|
gentleAgents(h.pi, {}, runtime.deps);
|
|
@@ -387,17 +482,17 @@ for (const mode of ["print", "tui", "rpc"] as const) {
|
|
|
387
482
|
Object.assign(ctx, { mode, hasUI: mode === "tui" });
|
|
388
483
|
await h.fire("session_start", ctx);
|
|
389
484
|
let resolved = false;
|
|
390
|
-
const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode:
|
|
485
|
+
const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: singleShot ? "task" : "background" }, undefined, undefined, ctx).then(result => { resolved = true; return result; });
|
|
391
486
|
await tick();
|
|
392
487
|
assert.equal(runtime.spawned.length, 1);
|
|
393
|
-
assert.equal(resolved,
|
|
488
|
+
assert.equal(resolved, !singleShot, "only task mode waits for completion");
|
|
394
489
|
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
395
490
|
runtime.children[0].emit({ type: "agent_settled" });
|
|
396
491
|
const result = await pending;
|
|
397
|
-
if (mode === "print") assert.match(result.content[0].text, /mapped/);
|
|
398
492
|
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
399
493
|
assert.ok(taskId);
|
|
400
|
-
if (
|
|
494
|
+
if (singleShot) assert.equal(result.content[0].text, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`, "the task result names its real id for subagent_continue");
|
|
495
|
+
if (!singleShot) {
|
|
401
496
|
await tick();
|
|
402
497
|
assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed · background/);
|
|
403
498
|
assert.equal(h.sent.length, 1, "settlement delivers exactly one completion");
|
|
@@ -652,7 +747,7 @@ test("first handoff failure keeps ordinary completion, later failure retains yie
|
|
|
652
747
|
firstHarness.children[0].message({ id: "q1", kind: "query", message: "q" });
|
|
653
748
|
firstHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "ordinary" }], stopReason: "stop" }] });
|
|
654
749
|
firstHarness.children[0].emit({ type: "agent_settled" });
|
|
655
|
-
assert.
|
|
750
|
+
assert.match((await ordinary).content[0].text, /^Subagent explore \(task [^,]+, "fail handoff"\) finished\.\n\nordinary$/);
|
|
656
751
|
|
|
657
752
|
const second = fakePi();
|
|
658
753
|
const secondHarness = deps();
|
|
@@ -1134,7 +1229,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
|
|
|
1134
1229
|
return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
|
|
1135
1230
|
source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
1136
1231
|
} };
|
|
1137
|
-
const env: NodeJS.ProcessEnv = {};
|
|
1232
|
+
const env: NodeJS.ProcessEnv = { GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
1138
1233
|
const profile = join(root, `metrics-${boundary}`);
|
|
1139
1234
|
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
1140
1235
|
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
@@ -1230,10 +1325,16 @@ function liveProfile(name: string): string {
|
|
|
1230
1325
|
return profile;
|
|
1231
1326
|
}
|
|
1232
1327
|
|
|
1233
|
-
function liveInstance(t: test.TestContext, profile: string, sessionId: string) {
|
|
1328
|
+
function liveInstance(t: test.TestContext, profile: string, sessionId: string, metadata = false) {
|
|
1234
1329
|
const h = fakePi();
|
|
1235
1330
|
const runtime = deps();
|
|
1236
1331
|
const context = fakeContext();
|
|
1332
|
+
if (metadata) {
|
|
1333
|
+
runtime.deps.sessionTransport = {
|
|
1334
|
+
...inertSessionTransport,
|
|
1335
|
+
createListener: registry => ({ registry, record: { version: 1, sessionId, endpoint: `/fixture/${sessionId}`, createdAt: 1 }, start: async () => {}, close: async () => {} }),
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1237
1338
|
Object.assign(context.ctx.sessionManager, {
|
|
1238
1339
|
getSessionId: () => sessionId,
|
|
1239
1340
|
getSessionName: () => sessionId,
|
|
@@ -1262,8 +1363,8 @@ async function liveOverlay(instance: ReturnType<typeof liveInstance>) {
|
|
|
1262
1363
|
|
|
1263
1364
|
test("live-only extension instances discover same-profile peers across cwd boundaries without importing their tasks", async (t) => {
|
|
1264
1365
|
const profile = liveProfile("live-peer-profile");
|
|
1265
|
-
const local = liveInstance(t, profile, "local-live");
|
|
1266
|
-
const peer = liveInstance(t, profile, "peer-live");
|
|
1366
|
+
const local = liveInstance(t, profile, "local-live", true);
|
|
1367
|
+
const peer = liveInstance(t, profile, "peer-live", true);
|
|
1267
1368
|
assert.equal(listPresence(profile).entries.length, 0, "factory construction starts no presence resources");
|
|
1268
1369
|
await local.fire("session_start", local.ctx, { reason: "startup" });
|
|
1269
1370
|
await peer.fire("session_start", peer.ctx, { reason: "startup" });
|
|
@@ -1278,6 +1379,10 @@ test("live-only extension instances discover same-profile peers across cwd bound
|
|
|
1278
1379
|
};
|
|
1279
1380
|
await run(local, "local");
|
|
1280
1381
|
const peerId = await run(peer, "peer");
|
|
1382
|
+
await eventually(() => listPresence(profile).entries.some(header => readDiscovery(profile, header)?.tasks.some(task => task.id === peerId)), "admitted runtime-owned tasks publish without subsequent child events");
|
|
1383
|
+
const scope = listPresence(profile).entries.map(header => readDiscovery(profile, header)?.scope).find(scope => scope?.tasks.some(task => task.id === peerId));
|
|
1384
|
+
assert.equal(scope?.tasks.find(task => task.id === peerId)?.repository.root, peer.ctx.sessionManager.getCwd());
|
|
1385
|
+
assert.equal(scope?.host.cloneHash, scope?.tasks.find(task => task.id === peerId)?.repository.cloneHash);
|
|
1281
1386
|
await tick();
|
|
1282
1387
|
peer.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "peer streamed text" } });
|
|
1283
1388
|
await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.summary.id === peerId && row.thread.items.some((item) => item.text === "peer streamed text"))), "task deltas, not just status changes, must publish peer activity");
|
|
@@ -1482,6 +1587,68 @@ test("live-only directory traverses presence overflow, excludes expired and othe
|
|
|
1482
1587
|
await panel.opened;
|
|
1483
1588
|
});
|
|
1484
1589
|
|
|
1590
|
+
// #1690: Pi drops unknown --tools names without a diagnostic, so the child
|
|
1591
|
+
// compares the runner's requested list with its own tools at the first
|
|
1592
|
+
// before_agent_start, after every extension's session_start has registered.
|
|
1593
|
+
function missingToolsChild(env: NodeJS.ProcessEnv, toolNames: string[] | (() => string[])) {
|
|
1594
|
+
const child = fakePi();
|
|
1595
|
+
Object.assign(child.pi, { getAllTools: () => (typeof toolNames === "function" ? toolNames() : toolNames).map((name) => ({ name })) });
|
|
1596
|
+
gentleAgents(child.pi, { GENTLE_PI_AGENTS_CHILD: "1", ...env });
|
|
1597
|
+
const notes: Array<{ message: string; type: unknown }> = [];
|
|
1598
|
+
const ctx = { hasUI: true, ui: { notify: (message: string, type?: unknown) => notes.push({ message, type }) } } as unknown as ExtensionContext;
|
|
1599
|
+
return {
|
|
1600
|
+
start: () => child.fire("session_start", ctx, { type: "session_start", reason: "startup" }),
|
|
1601
|
+
prompt: () => child.fire("before_agent_start", ctx, { type: "before_agent_start", prompt: "go", systemPrompt: "base", systemPromptOptions: {} }),
|
|
1602
|
+
notes,
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
test("a child warns once about requested tools it does not have", async () => {
|
|
1607
|
+
const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,gentle_review_scope,grep,codegraph,subagent_parent_message" }, ["read", "grep", "subagent_parent_message"]);
|
|
1608
|
+
await child.start();
|
|
1609
|
+
assert.deepEqual(child.notes, [], "session_start runs before later extensions register their tools");
|
|
1610
|
+
assert.deepEqual(await child.prompt(), [undefined], "the check never alters the prompt");
|
|
1611
|
+
assert.deepEqual(child.notes, [{ message: `${MISSING_TOOLS_NOTE_PREFIX} gentle_review_scope, codegraph`, type: "warning" }]);
|
|
1612
|
+
await child.start();
|
|
1613
|
+
assert.deepEqual(await child.prompt(), [undefined]);
|
|
1614
|
+
assert.equal(child.notes.length, 1, "later prompts and session replacements do not repeat the warning");
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
test("a child does not report a tool that a later extension registers in its own session_start", async () => {
|
|
1618
|
+
const tools = ["read", "grep"];
|
|
1619
|
+
const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,late_tool" }, () => tools);
|
|
1620
|
+
await child.start();
|
|
1621
|
+
// Pi runs session_start handlers in load order; an extension loaded after
|
|
1622
|
+
// gentle-agents registers late_tool only now.
|
|
1623
|
+
tools.push("late_tool");
|
|
1624
|
+
const results = await child.prompt();
|
|
1625
|
+
assert.deepEqual(child.notes, []);
|
|
1626
|
+
assert.deepEqual(results, [undefined]);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
test("a child stays quiet when every checkable requested tool exists or nothing was requested", async () => {
|
|
1630
|
+
for (const [label, env] of [
|
|
1631
|
+
["all present", { [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }],
|
|
1632
|
+
["MCP names register late", { [REQUESTED_TOOLS_ENV]: "read,mcp__github__search,mcp__x" }],
|
|
1633
|
+
["frontmatter wildcard entries are not tool names", { [REQUESTED_TOOLS_ENV]: "read,\"*\": false,*, ," }],
|
|
1634
|
+
["no requested list", {}],
|
|
1635
|
+
["empty requested list", { [REQUESTED_TOOLS_ENV]: "" }],
|
|
1636
|
+
] as const) {
|
|
1637
|
+
const child = missingToolsChild(env, ["read", "grep", "subagent_parent_message"]);
|
|
1638
|
+
await child.start();
|
|
1639
|
+
const results = await child.prompt();
|
|
1640
|
+
assert.ok(results.every((result) => result === undefined), label);
|
|
1641
|
+
assert.deepEqual(child.notes, [], label);
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
test("the missing-tools check never throws into before_agent_start", async () => {
|
|
1646
|
+
const child = missingToolsChild({ [REQUESTED_TOOLS_ENV]: "read,codegraph" }, () => { throw new Error("tool registry unavailable"); });
|
|
1647
|
+
await child.start();
|
|
1648
|
+
assert.deepEqual(await child.prompt(), [undefined]);
|
|
1649
|
+
assert.deepEqual(child.notes, []);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1485
1652
|
test("retired managed SDD child envelopes deny all tools without registering a delegation host", () => {
|
|
1486
1653
|
for (const env of [{ GENTLE_PI_RESEARCH_TOOLS: '["read"]' }, { GENTLE_PI_RESEARCH_SELECTION: '{}' }, { GENTLE_PI_RESEARCH_ARTIFACT: '{}' }, { GENTLE_PI_SDD_REMEDIATION_PLAN: '{}' }]) {
|
|
1487
1654
|
const hooks = new Map<string, (event: { toolName: string }) => { block: boolean }>();
|
|
@@ -1678,9 +1845,9 @@ test("C1 investigation: the relay mechanism is correct when every guard input is
|
|
|
1678
1845
|
await h.fire("session_shutdown", ctx);
|
|
1679
1846
|
await tick();
|
|
1680
1847
|
} finally {
|
|
1681
|
-
rmSync(repoRoot,
|
|
1682
|
-
rmSync(gitHome,
|
|
1683
|
-
rmSync(gitHooksDir,
|
|
1848
|
+
rmSync(repoRoot, TEST_DIR_REMOVAL);
|
|
1849
|
+
rmSync(gitHome, TEST_DIR_REMOVAL);
|
|
1850
|
+
rmSync(gitHooksDir, TEST_DIR_REMOVAL);
|
|
1684
1851
|
}
|
|
1685
1852
|
});
|
|
1686
1853
|
|
|
@@ -2053,7 +2220,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2053
2220
|
mkdirSync(gitTemplate);
|
|
2054
2221
|
execFileSync("git", ["init", "--quiet", `--template=${gitTemplate}`, canonicalGitCwd]);
|
|
2055
2222
|
} catch (error) {
|
|
2056
|
-
rmSync(canonicalGitFixture,
|
|
2223
|
+
rmSync(canonicalGitFixture, TEST_DIR_REMOVAL);
|
|
2057
2224
|
throw error;
|
|
2058
2225
|
}
|
|
2059
2226
|
childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
|
|
@@ -2066,6 +2233,8 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2066
2233
|
try {
|
|
2067
2234
|
const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
|
|
2068
2235
|
const h = fakePi();
|
|
2236
|
+
// The launch reads the global active profile; keep it on the scratch config home.
|
|
2237
|
+
env = { ...env, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
2069
2238
|
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
|
|
2070
2239
|
const { ctx } = fakeContext();
|
|
2071
2240
|
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
@@ -2097,7 +2266,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2097
2266
|
assert.equal(captured[index]?.command, "/fixture/pi");
|
|
2098
2267
|
assert.deepEqual(captured[index]?.args, args);
|
|
2099
2268
|
assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
|
|
2100
|
-
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}) });
|
|
2269
|
+
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" });
|
|
2101
2270
|
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
2102
2271
|
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
2103
2272
|
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
@@ -2110,7 +2279,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2110
2279
|
try {
|
|
2111
2280
|
await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
|
|
2112
2281
|
} finally {
|
|
2113
|
-
rmSync(canonicalGitFixture,
|
|
2282
|
+
rmSync(canonicalGitFixture, TEST_DIR_REMOVAL);
|
|
2114
2283
|
}
|
|
2115
2284
|
}
|
|
2116
2285
|
});
|
|
@@ -2226,6 +2395,9 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2226
2395
|
await tick();
|
|
2227
2396
|
assert.deepEqual(spawned, [foreign]);
|
|
2228
2397
|
assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
|
|
2398
|
+
// The parent session froze "no profile" (no pin, no global active), so the
|
|
2399
|
+
// foreign launch keeps the foreign repository's own pin (gentle-shell#1064
|
|
2400
|
+
// 3b-ii). Dropping `foreignRepository` from the launch wiring breaks this.
|
|
2229
2401
|
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
|
|
2230
2402
|
assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2231
2403
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
@@ -2273,7 +2445,35 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2273
2445
|
await tick();
|
|
2274
2446
|
assert.equal(runtime.children.length, 3, "stale queued foreign task must fail before OS spawn");
|
|
2275
2447
|
await h.fire("session_shutdown", successor);
|
|
2276
|
-
} finally { rmSync(fixture,
|
|
2448
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
test("a frozen session profile routes a foreign repository launch over that repository's pin", async (t) => {
|
|
2452
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-frozen-")));
|
|
2453
|
+
t.after(() => rmSync(fixture, TEST_DIR_REMOVAL));
|
|
2454
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2455
|
+
mkdirSync(template);
|
|
2456
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2457
|
+
const configHome = join(fixture, "config");
|
|
2458
|
+
mkdirSync(configHome);
|
|
2459
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "session", profiles: {
|
|
2460
|
+
session: { explore: { model: "openai/session-model", thinking: "low" } },
|
|
2461
|
+
pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } },
|
|
2462
|
+
} }));
|
|
2463
|
+
mkdirSync(join(foreign, ".git", "gentle-ai"));
|
|
2464
|
+
writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
|
|
2465
|
+
const h = fakePi(), runtime = deps();
|
|
2466
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
|
|
2467
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2468
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2469
|
+
const { ctx } = fakeContext();
|
|
2470
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2471
|
+
await h.fire("session_start", ctx);
|
|
2472
|
+
t.after(async () => { await h.fire("session_shutdown", ctx); await tick(); });
|
|
2473
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "session", "the parent froze the global active profile");
|
|
2474
|
+
await h.tools.get("subagent_run")!.execute("foreign-frozen", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2475
|
+
await tick();
|
|
2476
|
+
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/session-model:low", "the session profile wins over the foreign pin");
|
|
2277
2477
|
});
|
|
2278
2478
|
|
|
2279
2479
|
test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
|
|
@@ -2357,7 +2557,7 @@ test("foreign child Changes require successful target-bound tool evidence, never
|
|
|
2357
2557
|
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3, "session replacement during a tool cannot attribute its result");
|
|
2358
2558
|
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 0);
|
|
2359
2559
|
await h.fire("session_shutdown", successor);
|
|
2360
|
-
} finally { rmSync(fixture,
|
|
2560
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2361
2561
|
});
|
|
2362
2562
|
|
|
2363
2563
|
test("foreign task mode waits for the child and continuation reuses its live grant", async () => {
|
|
@@ -2405,7 +2605,7 @@ test("foreign task mode waits for the child and continuation reuses its live gra
|
|
|
2405
2605
|
assert.equal(((await continued).details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2406
2606
|
assert.equal(runtime.spawned.length, 2);
|
|
2407
2607
|
await h.fire("session_shutdown", ctx);
|
|
2408
|
-
} finally { rmSync(fixture,
|
|
2608
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2409
2609
|
});
|
|
2410
2610
|
|
|
2411
2611
|
test("aborting during foreign consent cannot grant or queue a background child", async () => {
|
|
@@ -2430,7 +2630,7 @@ test("aborting during foreign consent cannot grant or queue a background child",
|
|
|
2430
2630
|
assert.equal(runtime.children.length, 0);
|
|
2431
2631
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2432
2632
|
await h.fire("session_shutdown", ctx);
|
|
2433
|
-
} finally { rmSync(fixture,
|
|
2633
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2434
2634
|
});
|
|
2435
2635
|
|
|
2436
2636
|
test("an interactive non-Git umbrella can target an independent repository without registering it", async () => {
|
|
@@ -2450,7 +2650,7 @@ test("an interactive non-Git umbrella can target an independent repository witho
|
|
|
2450
2650
|
assert.equal((launched.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2451
2651
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2452
2652
|
await h.fire("session_shutdown", ctx);
|
|
2453
|
-
} finally { rmSync(fixture,
|
|
2653
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2454
2654
|
});
|
|
2455
2655
|
|
|
2456
2656
|
test("foreign selector rejects RPC and retired SDD selections before consent", async () => {
|
|
@@ -2486,7 +2686,7 @@ test("foreign selector rejects RPC and retired SDD selections before consent", a
|
|
|
2486
2686
|
assert.equal(dialogs.length, 0);
|
|
2487
2687
|
assert.equal(runtime.children.length, 0);
|
|
2488
2688
|
await h.fire("session_shutdown", ctx);
|
|
2489
|
-
} finally { rmSync(fixture,
|
|
2689
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2490
2690
|
});
|
|
2491
2691
|
|
|
2492
2692
|
test("foreign clone rejects aliases, absent UI, decline and changed session before any child starts", async () => {
|
|
@@ -2520,7 +2720,7 @@ test("foreign clone rejects aliases, absent UI, decline and changed session befo
|
|
|
2520
2720
|
assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
|
|
2521
2721
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2522
2722
|
await h.fire("session_shutdown", ctx);
|
|
2523
|
-
} finally { rmSync(fixture,
|
|
2723
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
2524
2724
|
});
|
|
2525
2725
|
|
|
2526
2726
|
test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
|
|
@@ -2548,6 +2748,7 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
|
|
|
2548
2748
|
await h.fire("session_start", ctx);
|
|
2549
2749
|
const run = h.tools.get("subagent_run")!;
|
|
2550
2750
|
await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /same Git clone/);
|
|
2751
|
+
await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /subagent_run with repository_root/);
|
|
2551
2752
|
assert.deepEqual(launched, []);
|
|
2552
2753
|
const result = await run.execute("one", { agent: "explore", task: "Map /other-clone mentioned in prose", workspace_root: childRoot, mode: "background" }, undefined, undefined, ctx);
|
|
2553
2754
|
await tick();
|
|
@@ -2565,12 +2766,12 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
|
|
|
2565
2766
|
await tick();
|
|
2566
2767
|
assert.deepEqual(launched, [childRoot, childRoot]);
|
|
2567
2768
|
spawnEvents[1]();
|
|
2568
|
-
assert.equal(h.entries.length, 1, "continuation dedupes the original root");
|
|
2769
|
+
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "continuation dedupes the original root");
|
|
2569
2770
|
const status = await h.tools.get("subagent_status")!.execute("status", { task_id: details.taskId }, undefined, undefined, ctx);
|
|
2570
2771
|
assert.match(status.content[0].text, /cwd:/);
|
|
2571
2772
|
await h.fire("session_shutdown", ctx);
|
|
2572
2773
|
spawnEvents[1]();
|
|
2573
|
-
assert.equal(h.entries.length, 1, "late process events after shutdown cannot
|
|
2774
|
+
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1, "late process events after shutdown cannot register roots");
|
|
2574
2775
|
await tick();
|
|
2575
2776
|
});
|
|
2576
2777
|
|
|
@@ -2664,7 +2865,8 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2664
2865
|
Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
|
|
2665
2866
|
ctx.sessionManager.getCwd = () => project;
|
|
2666
2867
|
ctx.sessionManager.getEntries = () => h.entries as never;
|
|
2667
|
-
|
|
2868
|
+
// The repository declaration exists before the session starts: the session
|
|
2869
|
+
// freezes it at session_start (gentle-shell#1064 slice 3b-ii).
|
|
2668
2870
|
if (scenario === "profile-model" || scenario === "profile-valid") {
|
|
2669
2871
|
mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
|
|
2670
2872
|
writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
|
|
@@ -2672,6 +2874,7 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2672
2874
|
mkdirSync(config, { recursive: true });
|
|
2673
2875
|
writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { invalid: { worker: { model: scenario === "profile-valid" ? "offline/pinned-good" : "offline/bad" } } } }));
|
|
2674
2876
|
}
|
|
2877
|
+
await h.fire("session_start", ctx);
|
|
2675
2878
|
let calls = 0;
|
|
2676
2879
|
const abort = new AbortController();
|
|
2677
2880
|
const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
|
|
@@ -2702,6 +2905,52 @@ for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-a
|
|
|
2702
2905
|
});
|
|
2703
2906
|
}
|
|
2704
2907
|
|
|
2908
|
+
// gentle-shell#1558 (barbatdev review): the non-git writer admission and the
|
|
2909
|
+
// launch path must resolve the same model. Before the session-binding layer,
|
|
2910
|
+
// both read pin/global only; with a binding present the launch resolves it
|
|
2911
|
+
// first while admission still read the declaration, so a bound session killed
|
|
2912
|
+
// its own writer mid-preparation with a false "profile or session changed".
|
|
2913
|
+
test("a session binding keeps writer admission and launch resolution in agreement", async t => {
|
|
2914
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
2915
|
+
const fixture = realpathSync(mkdtempSync(join(root, "writer-session-binding-")));
|
|
2916
|
+
const project = join(fixture, "project");
|
|
2917
|
+
const fixtureHome = join(fixture, "home");
|
|
2918
|
+
const definitions = join(fixtureHome, ".pi", "agent", "agents");
|
|
2919
|
+
mkdirSync(definitions, { recursive: true });
|
|
2920
|
+
mkdirSync(project);
|
|
2921
|
+
writeFileSync(join(definitions, "worker.md"), "---\ndescription: fixture\nmodel: offline/good\ntools: [read]\n---\nFixture");
|
|
2922
|
+
// The unversioned project declaration routes the writer at one model and the
|
|
2923
|
+
// session binding at another: only agreement between the two resolutions can
|
|
2924
|
+
// let this launch through, and both must pick the binding's model.
|
|
2925
|
+
mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
|
|
2926
|
+
writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "declared" }));
|
|
2927
|
+
const configHome = join(fixture, "config");
|
|
2928
|
+
mkdirSync(configHome, { recursive: true });
|
|
2929
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { declared: { worker: { model: "offline/pinned-good" } }, bound: { worker: { model: "offline/bound-good" } } } }));
|
|
2930
|
+
bindSessionProfile("s1", "bound", { worker: { model: "offline/bound-good" } });
|
|
2931
|
+
const h = fakePi();
|
|
2932
|
+
const runtime = deps();
|
|
2933
|
+
runtime.deps.home = fixtureHome;
|
|
2934
|
+
runtime.deps.env!.GENTLE_PI_CONFIG_HOME = configHome;
|
|
2935
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2936
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2937
|
+
const { ctx } = fakeContext();
|
|
2938
|
+
Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => ({ provider: "offline", id: model }) } });
|
|
2939
|
+
ctx.sessionManager.getCwd = () => project;
|
|
2940
|
+
await h.fire("session_start", ctx);
|
|
2941
|
+
const unbind = bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
|
|
2942
|
+
if (!current()) return false;
|
|
2943
|
+
execFileSync("git", ["init", "--quiet", project], { env: { PATH: process.env.PATH, HOME: fixtureHome, GIT_CONFIG_NOSYSTEM: "1" }, stdio: "pipe" });
|
|
2944
|
+
return true;
|
|
2945
|
+
}, () => true);
|
|
2946
|
+
try {
|
|
2947
|
+
await h.tools.get("subagent_run")!.execute("session-binding-admission", { agent: "worker", task: "Implement source\n## Allowed edit surfaces\nsrc/app.ts\n## Return\nReport", mode: "background" }, undefined, undefined, ctx);
|
|
2948
|
+
await tick();
|
|
2949
|
+
assert.equal(runtime.children.length, 1, "the bound session's writer launches instead of dying as a false profile/session drift");
|
|
2950
|
+
assert.ok(runtime.spawned[0]?.includes("offline/bound-good"), "admission and launch both resolve the binding ahead of the project declaration");
|
|
2951
|
+
} finally { unbind(); await h.fire("session_shutdown", ctx); await tick(); }
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2705
2954
|
for (const explicit of [false, true]) {
|
|
2706
2955
|
test(`same manager and ID after bootstrap permit ${explicit ? "explicit" : "implicit"} launch registration`, async () => {
|
|
2707
2956
|
const h = fakePi();
|
|
@@ -2757,7 +3006,7 @@ test("ordinary non-Git tasks still continue in their original cwd without regist
|
|
|
2757
3006
|
await h.tools.get("subagent_continue")!.execute("continue", { task_id: taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
|
|
2758
3007
|
await tick();
|
|
2759
3008
|
assert.equal(runtime.children.length, 2);
|
|
2760
|
-
assert.deepEqual(h.entries, []);
|
|
3009
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), []);
|
|
2761
3010
|
await h.fire("session_shutdown", ctx);
|
|
2762
3011
|
await tick();
|
|
2763
3012
|
});
|
|
@@ -2788,7 +3037,7 @@ for (const sameId of [false, true]) {
|
|
|
2788
3037
|
await h.fire("session_start", next.ctx);
|
|
2789
3038
|
spawnEvents[0]();
|
|
2790
3039
|
await tick();
|
|
2791
|
-
assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
|
|
3040
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType !== "gentle-agents.wake-identity"), [], "captured registry is closed instead of appending worktree state to the new bound API");
|
|
2792
3041
|
await h.fire("session_shutdown", next.ctx);
|
|
2793
3042
|
});
|
|
2794
3043
|
}
|
|
@@ -2887,8 +3136,8 @@ function pinFixture(name: string) {
|
|
|
2887
3136
|
declarationPath,
|
|
2888
3137
|
writePin: (profile: string) => writePinText(localPinPath, profile),
|
|
2889
3138
|
writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
|
|
2890
|
-
writeStore: (profiles: Record<string, unknown
|
|
2891
|
-
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
|
|
3139
|
+
writeStore: (profiles: Record<string, unknown>, active?: string) => {
|
|
3140
|
+
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, ...(active === undefined ? {} : { active }), profiles }, null, 2)}\n`);
|
|
2892
3141
|
},
|
|
2893
3142
|
};
|
|
2894
3143
|
}
|
|
@@ -2965,6 +3214,170 @@ test("a stale or unreadable pin degrades to the global routing instead of failin
|
|
|
2965
3214
|
assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
|
|
2966
3215
|
});
|
|
2967
3216
|
|
|
3217
|
+
// gentle-shell#1558 (barbatdev review): the two registered launch-seam gaps.
|
|
3218
|
+
// The pure helpers were covered, but the real subagent_run seam had no
|
|
3219
|
+
// committed test for the session layer.
|
|
3220
|
+
test("a session binding outranks the repository pin at the launch seam", async t => {
|
|
3221
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3222
|
+
const base = pinFixture("session-over-pin");
|
|
3223
|
+
base.writeStore({
|
|
3224
|
+
pinned: { explore: { model: "openai/alpha", thinking: "minimal" } },
|
|
3225
|
+
bound: { explore: { model: "openai/beta" } },
|
|
3226
|
+
});
|
|
3227
|
+
base.writePin("pinned");
|
|
3228
|
+
// fakeContext's session id is "s1"; the binding is process state, so it is
|
|
3229
|
+
// bound before the launch and reset by t.after so later pin tests stay pure.
|
|
3230
|
+
bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
|
|
3231
|
+
assert.equal(await launchPinned(base), "openai/beta:high", "the session binding wins over a winning repository pin");
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
test("a queued launch keeps the session routing frozen across a rebind", async t => {
|
|
3235
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3236
|
+
const base = pinFixture("queue-freeze");
|
|
3237
|
+
base.writeStore({
|
|
3238
|
+
first: { explore: { model: "openai/alpha", thinking: "minimal" } },
|
|
3239
|
+
second: { explore: { model: "openai/beta" } },
|
|
3240
|
+
});
|
|
3241
|
+
bindSessionProfile("s1", "first", { explore: { model: "openai/alpha", thinking: "minimal" } });
|
|
3242
|
+
const harness = deps();
|
|
3243
|
+
harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
|
|
3244
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome };
|
|
3245
|
+
const { pi, tools, fire } = fakePi();
|
|
3246
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3247
|
+
const { ctx } = fakeContext();
|
|
3248
|
+
await fire("session_start", ctx);
|
|
3249
|
+
try {
|
|
3250
|
+
const model = (args: string[]) => args[args.indexOf("--model") + 1];
|
|
3251
|
+
// The first launch freezes first's routing into its task request and its
|
|
3252
|
+
// spawned child. Rebinding the session must leave that request untouched;
|
|
3253
|
+
// only a request created afterwards resolves the new binding.
|
|
3254
|
+
await tools.get("subagent_run")!.execute("freeze-1", { agent: "explore", task: "Map first", mode: "background" }, undefined, undefined, ctx);
|
|
3255
|
+
bindSessionProfile("s1", "second", { explore: { model: "openai/beta" } });
|
|
3256
|
+
await tools.get("subagent_run")!.execute("freeze-2", { agent: "explore", task: "Map second", mode: "background" }, undefined, undefined, ctx);
|
|
3257
|
+
await tick();
|
|
3258
|
+
assert.equal(model(harness.spawned[0]), "openai/alpha:minimal", "the request created before the rebind keeps its frozen routing");
|
|
3259
|
+
assert.equal(model(harness.spawned[1]), "openai/beta:high", "only requests created after the rebind resolve the new binding");
|
|
3260
|
+
} finally {
|
|
3261
|
+
await fire("session_shutdown", ctx);
|
|
3262
|
+
await tick();
|
|
3263
|
+
}
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
// gentle-shell#1064 slice 3b-ii: a parent session without an explicit Enter
|
|
3267
|
+
// binding freezes p → P → G at session_start; later default changes only show
|
|
3268
|
+
// a drift notice once per change. GENTLE_PI_PROFILE_FOLLOW=1 keeps the live
|
|
3269
|
+
// resolution.
|
|
3270
|
+
async function launchSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
|
|
3271
|
+
const harness = deps();
|
|
3272
|
+
harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
|
|
3273
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
|
|
3274
|
+
const { pi, tools, fire } = fakePi();
|
|
3275
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3276
|
+
const { ctx, dialogs } = fakeContext();
|
|
3277
|
+
await fire("session_start", ctx);
|
|
3278
|
+
let count = 0;
|
|
3279
|
+
return {
|
|
3280
|
+
dialogs,
|
|
3281
|
+
driftNotices: () => dialogs.filter((dialog) => dialog.includes("the default profile changed")),
|
|
3282
|
+
launch: async () => {
|
|
3283
|
+
await tools.get("subagent_run")!.execute(`drift-${count}`, { agent: "explore", task: `Map ${count}`, mode: "background" }, undefined, undefined, ctx);
|
|
3284
|
+
await tick();
|
|
3285
|
+
const args = harness.spawned[count];
|
|
3286
|
+
// Settle the child so the next launch is not held by the concurrency cap.
|
|
3287
|
+
const child = harness.children[count++]!;
|
|
3288
|
+
child.emit({ type: "agent_end", messages: [] });
|
|
3289
|
+
child.emit({ type: "agent_settled" });
|
|
3290
|
+
child.exit(0);
|
|
3291
|
+
await tick();
|
|
3292
|
+
return args[args.indexOf("--model") + 1];
|
|
3293
|
+
},
|
|
3294
|
+
shutdown: async () => {
|
|
3295
|
+
await fire("session_shutdown", ctx);
|
|
3296
|
+
await tick();
|
|
3297
|
+
},
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
const DRIFT_PROFILES = {
|
|
3302
|
+
frontier: { explore: { model: "openai/alpha", thinking: "minimal" } },
|
|
3303
|
+
local: { explore: { model: "openai/beta" } },
|
|
3304
|
+
};
|
|
3305
|
+
|
|
3306
|
+
test("session_start freezes the inherited pin: a later pin change does not move the open session", async t => {
|
|
3307
|
+
const base = pinFixture("freeze-pin");
|
|
3308
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3309
|
+
base.writePin("frontier");
|
|
3310
|
+
const session = await launchSession(base);
|
|
3311
|
+
t.after(session.shutdown);
|
|
3312
|
+
assert.equal(readFrozenInheritedProfile("s1")?.profile?.name, "frontier", "frozen at session_start, before any launch");
|
|
3313
|
+
base.writePin("local");
|
|
3314
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3315
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3316
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3317
|
+
'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3318
|
+
], "one notice for one change, across two launches");
|
|
3319
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3320
|
+
base.writeDeclaration("local");
|
|
3321
|
+
rmSync(base.localPinPath);
|
|
3322
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3323
|
+
assert.equal(session.driftNotices().length, 1, "the same default reached through another layer is not a new change");
|
|
3324
|
+
});
|
|
3325
|
+
|
|
3326
|
+
test("an unpinned session routes through the global active profile and freezes it", async t => {
|
|
3327
|
+
const base = pinFixture("freeze-global");
|
|
3328
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3329
|
+
const session = await launchSession(base);
|
|
3330
|
+
t.after(session.shutdown);
|
|
3331
|
+
base.writeStore(DRIFT_PROFILES, "local");
|
|
3332
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the global active profile replaces the materialized routing");
|
|
3333
|
+
assert.equal(session.driftNotices().length, 1);
|
|
3334
|
+
assert.match(session.driftNotices()[0]!, /changed to "local" \(global\), this session keeps "frontier" \(global\)/);
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
test("a session with no profile anywhere keeps today's routing even after a pin appears", async t => {
|
|
3338
|
+
const base = pinFixture("freeze-none");
|
|
3339
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3340
|
+
const session = await launchSession(base);
|
|
3341
|
+
t.after(session.shutdown);
|
|
3342
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low", "today's materialized routing");
|
|
3343
|
+
base.writePin("frontier");
|
|
3344
|
+
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
|
|
3345
|
+
assert.deepEqual(session.driftNotices(), [
|
|
3346
|
+
'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
|
|
3347
|
+
]);
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
test("an explicit Enter binding wins over the frozen profile and never shows the drift notice", async t => {
|
|
3351
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3352
|
+
const base = pinFixture("freeze-explicit");
|
|
3353
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3354
|
+
base.writePin("frontier");
|
|
3355
|
+
const session = await launchSession(base);
|
|
3356
|
+
t.after(session.shutdown);
|
|
3357
|
+
bindSessionProfile("s1", "local", { explore: { model: "openai/beta" } });
|
|
3358
|
+
base.writePin("local");
|
|
3359
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3360
|
+
rmSync(base.localPinPath);
|
|
3361
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3362
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
test("follow mode re-resolves the defaults on every launch without a notice; Enter makes it explicit", async t => {
|
|
3366
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3367
|
+
const base = pinFixture("follow");
|
|
3368
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3369
|
+
base.writePin("frontier");
|
|
3370
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3371
|
+
t.after(session.shutdown);
|
|
3372
|
+
assert.equal(readFrozenInheritedProfile("s1"), undefined, "follow never freezes");
|
|
3373
|
+
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3374
|
+
base.writePin("local");
|
|
3375
|
+
assert.equal(await session.launch(), "openai/beta:high");
|
|
3376
|
+
bindSessionProfile("s1", "frontier", { explore: { model: "openai/alpha", thinking: "minimal" } });
|
|
3377
|
+
assert.equal(await session.launch(), "openai/alpha:minimal", "the explicit binding stops following");
|
|
3378
|
+
assert.deepEqual(session.driftNotices(), []);
|
|
3379
|
+
});
|
|
3380
|
+
|
|
2968
3381
|
test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
|
|
2969
3382
|
assert.equal(agentsEnabled({}), true);
|
|
2970
3383
|
assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
|
|
@@ -2997,13 +3410,42 @@ test("while pi-subagents-j0k3r is still installed the tools stay unregistered an
|
|
|
2997
3410
|
assert.match(notices[0] ?? "", /^warning:❀ Gentle Agents is waiting: remove the old package first with "pi remove npm:pi-subagents-j0k3r"/);
|
|
2998
3411
|
});
|
|
2999
3412
|
|
|
3413
|
+
test("subagent_list_agents publishes each agent's declared tool inventory so routing can check capabilities", async () => {
|
|
3414
|
+
const fixture = mkdtempSync(join(tmpdir(), "capability-catalog-"));
|
|
3415
|
+
const agentHome = join(fixture, "agent-home");
|
|
3416
|
+
mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
|
|
3417
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\ntools: [read, grep, find, codegraph]\n---\nYou map things.");
|
|
3418
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "open.md"), "---\ndescription: no allowlist\n---\nYou are open.");
|
|
3419
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "unknown.md"), "---\ndescription: unverified name\ntools: [glob]\n---\nYou explore.");
|
|
3420
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "lens.md"), "---\ndescription: review lane\ntools:\n - \"*\": false\n - read\n - grep\n - gentle_review_scope\n---\nYou review.");
|
|
3421
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "denied.md"), "---\ndescription: only a wildcard\ntools:\n - \"*\": false\n---\nYou are denied.");
|
|
3422
|
+
try {
|
|
3423
|
+
const h = fakePi(), runtime = deps();
|
|
3424
|
+
runtime.deps.home = agentHome;
|
|
3425
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
3426
|
+
const { ctx } = fakeContext();
|
|
3427
|
+
await h.fire("session_start", ctx);
|
|
3428
|
+
const listed = await h.tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
|
|
3429
|
+
assert.match(listed.content[0].text, /- explore \(global\): maps things \[declared tools \(not verified\): read, grep, find, codegraph\]/);
|
|
3430
|
+
// gentle-shell#1269: a definition without an allowlist keeps Pi's default
|
|
3431
|
+
// tool set, so the catalog must never render it as having no tools at all.
|
|
3432
|
+
assert.match(listed.content[0].text, /- open \(global\): no allowlist \[declared tools: Pi defaults \(no allowlist\)\]/);
|
|
3433
|
+
// The review lane declares the `"*": false` wildcard, which Pi drops: it is
|
|
3434
|
+
// not a capability and must not appear as one in the routing surface.
|
|
3435
|
+
assert.match(listed.content[0].text, /- lens \(global\): review lane \[declared tools \(not verified\): read, grep, gentle_review_scope\]/);
|
|
3436
|
+
assert.doesNotMatch(listed.content[0].text, /false/);
|
|
3437
|
+
assert.match(listed.content[0].text, /- unknown \(global\): unverified name \[declared tools \(not verified\): glob\]/);
|
|
3438
|
+
assert.match(listed.content[0].text, /- denied \(global\): only a wildcard \[declared tools: no tool names declared\]/);
|
|
3439
|
+
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
3440
|
+
});
|
|
3441
|
+
|
|
3000
3442
|
test("subagent_list_agents and subagent_run in task mode launch a child with the resolved profile and return its answer", async () => {
|
|
3001
3443
|
const { pi, tools, fire } = fakePi();
|
|
3002
3444
|
const harness = deps();
|
|
3003
3445
|
gentleAgents(pi, {}, harness.deps);
|
|
3004
3446
|
const { ctx, widget } = fakeContext();
|
|
3005
3447
|
await fire("session_start", ctx);
|
|
3006
|
-
assert.deepEqual([...tools.keys()].sort(), ["orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
|
|
3448
|
+
assert.deepEqual([...tools.keys()].sort(), ["bash_background", "job_list", "job_stop", "monitor", "orchestrator_consult", "orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
|
|
3007
3449
|
const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
|
|
3008
3450
|
assert.match(listed.content[0].text, /- explore \(global\): maps things/);
|
|
3009
3451
|
|
|
@@ -3023,7 +3465,9 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
3023
3465
|
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
|
|
3024
3466
|
harness.children[0].emit({ type: "agent_settled" });
|
|
3025
3467
|
const result = await running;
|
|
3026
|
-
|
|
3468
|
+
const runId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
3469
|
+
assert.equal(result.content[0].text, `Subagent explore (task ${runId}, "map lib modules") finished.\n\nlib has three agent files.`, "the model sees the real task id and label, not only details");
|
|
3470
|
+
assert.deepEqual(tools.get("subagent_run")!.renderResult(result as Parameters<Registered["renderResult"]>[0], { expanded: false }, plainTheme).render(80).map((line) => line.trimEnd()), ["lib has three agent files."], "the collapsed card still leads with the answer");
|
|
3027
3471
|
assert.equal((result.details.gentleAgents as { status: string }).status, "completed");
|
|
3028
3472
|
assert.match(widget()![1], /✓ explore map lib modules/);
|
|
3029
3473
|
const orphan = tools.get("subagent_run")!.execute("c9", { agent: "explore", task: "Orphan", mode: "background" }, undefined, undefined, ctx);
|
|
@@ -3129,9 +3573,15 @@ test("background runs return at once; status, result, send_message, cancel, and
|
|
|
3129
3573
|
await tick();
|
|
3130
3574
|
harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Summary." }] }] });
|
|
3131
3575
|
harness.children[1].emit({ type: "agent_settled" });
|
|
3132
|
-
|
|
3576
|
+
const summary = await resumed;
|
|
3577
|
+
assert.equal(summary.content[0].text, `Subagent explore (task ${(summary.details.gentleAgents as { taskId: string }).taskId}, "Now summarize") finished.\n\nSummary.`);
|
|
3133
3578
|
assert.match((await tools.get("subagent_cancel")!.execute("c9", { task_id: id }, undefined, undefined, ctx)).content[0].text, /not running/);
|
|
3134
3579
|
assert.match((await tools.get("subagent_status")!.execute("c10", { task_id: "nope" }, undefined, undefined, ctx)).content[0].text, /Error: no task nope/);
|
|
3580
|
+
// gentle-shell#1713: a guessed id ("1") must point back to real ids.
|
|
3581
|
+
// Review follow-up: a call without a context still gets the structured error.
|
|
3582
|
+
assert.match((await tools.get("subagent_status")!.execute("c10c", { task_id: "1" }, undefined, undefined, undefined as never)).content[0].text, /^Error: no task 1\./);
|
|
3583
|
+
for (const name of ["subagent_status", "subagent_result"]) assert.match((await tools.get(name)!.execute("c10a", { task_id: "1" }, undefined, undefined, ctx)).content[0].text, new RegExp(`^Error: no task 1\\. Recent task ids: .*${id} \\(explore\\)`), name);
|
|
3584
|
+
assert.match((await tools.get("subagent_continue")!.execute("c10b", { task_id: "1", prompt: "more" }, undefined, undefined, ctx)).content[0].text, new RegExp(`^Error: no task 1\\. Recent task ids: .*${id} \\(explore\\)`));
|
|
3135
3585
|
assert.match((await tools.get("subagent_run")!.execute("c11", { agent: "ghost", task: "x" }, undefined, undefined, ctx)).content[0].text, /no subagent named "ghost"\. Known: explore/);
|
|
3136
3586
|
});
|
|
3137
3587
|
|
|
@@ -3329,7 +3779,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
|
|
|
3329
3779
|
const harness = deps();
|
|
3330
3780
|
gentleAgents(pi, {}, harness.deps);
|
|
3331
3781
|
const { ctx, overlays, customCompletions } = fakeContext();
|
|
3332
|
-
(ctx
|
|
3782
|
+
Object.assign(ctx.sessionManager, { getSessionId: () => "footer-session" });
|
|
3333
3783
|
await fire("session_start", ctx);
|
|
3334
3784
|
await tools.get("subagent_run")!.execute("c1", { agent: "寿司", task: "Footer target", mode: "background" }, undefined, undefined, ctx);
|
|
3335
3785
|
await tick();
|
|
@@ -3530,17 +3980,45 @@ test("the overlay explains that stopping a waiting subagent dismisses its questi
|
|
|
3530
3980
|
await opened;
|
|
3531
3981
|
});
|
|
3532
3982
|
|
|
3533
|
-
test("restored task history cannot enter the live panel or
|
|
3983
|
+
test("restored task history cannot enter the live panel or discovery during synchronous summary callbacks", async (t) => {
|
|
3534
3984
|
const { pi, fire, commands, tools } = fakePi();
|
|
3535
3985
|
const harness = deps();
|
|
3536
3986
|
const historyHome = join(root, "history-home");
|
|
3537
3987
|
const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "s1", status: TASK_STATUS.RUNNING, createdAt: 1, startedAt: 1, endedAt: null, model: "m", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "working", lastActivityAt: 1, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
|
|
3538
|
-
await saveTask(historyDir(historyHome), historical, emptyThread());
|
|
3539
3988
|
harness.deps.home = historyHome;
|
|
3989
|
+
const profile = liveProfile("restored-discovery");
|
|
3990
|
+
await saveTask(historyDir(historyHome, profile), historical, emptyThread());
|
|
3991
|
+
chmodSync(join(profile, "gentle-agents"), 0o755);
|
|
3992
|
+
harness.deps.agentHome = profile;
|
|
3993
|
+
const registry = { list: async () => [], listActivations: async () => [] };
|
|
3994
|
+
harness.deps.sessionTransport = {
|
|
3995
|
+
createRegistry: async () => registry,
|
|
3996
|
+
createListener: (_, sessionId) => ({ registry, record: { version: 1, sessionId, endpoint: "/fixture/restore", createdAt: 1 }, start: async () => {}, close: async () => {} }),
|
|
3997
|
+
createClient: inertSessionTransport.createClient,
|
|
3998
|
+
};
|
|
3999
|
+
let checked = false;
|
|
4000
|
+
let leaked: unknown;
|
|
4001
|
+
const update = PresencePublisher.prototype.update;
|
|
4002
|
+
let snapshot: unknown;
|
|
4003
|
+
t.mock.method(PresencePublisher.prototype, "update", function (this: PresencePublisher, input: Parameters<PresencePublisher["update"]>[0]) {
|
|
4004
|
+
snapshot = input.map(row => row.task.id);
|
|
4005
|
+
return update.call(this, input);
|
|
4006
|
+
});
|
|
4007
|
+
const restore = TaskStore.prototype.restore;
|
|
4008
|
+
t.mock.method(TaskStore.prototype, "restore", function (this: TaskStore, ...args: Parameters<TaskStore["restore"]>) {
|
|
4009
|
+
const result = restore.apply(this, args);
|
|
4010
|
+
leaked = snapshot;
|
|
4011
|
+
checked = true;
|
|
4012
|
+
return result;
|
|
4013
|
+
});
|
|
3540
4014
|
gentleAgents(pi, {}, harness.deps);
|
|
3541
4015
|
const { ctx, dialogs, overlays } = fakeContext();
|
|
3542
4016
|
await fire("session_start", ctx);
|
|
3543
|
-
await
|
|
4017
|
+
await tick();
|
|
4018
|
+
await fire("session_start", ctx, { reason: "resume" });
|
|
4019
|
+
await eventually(() => checked, "restoration callback checked");
|
|
4020
|
+
assert.deepEqual(leaked, [], "summary callback must not publish restored running history");
|
|
4021
|
+
assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.tasks, []);
|
|
3544
4022
|
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
3545
4023
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
3546
4024
|
assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).join("\n")), /Stop selected|Subagent explore/);
|
|
@@ -3690,8 +4168,8 @@ test("the card follows the active session: after /new the earlier session's task
|
|
|
3690
4168
|
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
|
|
3691
4169
|
await tick();
|
|
3692
4170
|
assert.match(widget()![1], /◐ explore Long job/);
|
|
3693
|
-
const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string } };
|
|
3694
|
-
sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd };
|
|
4171
|
+
const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): [] } };
|
|
4172
|
+
sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
|
|
3695
4173
|
await fire("session_start", ctx, { type: "session_start", reason: "new" });
|
|
3696
4174
|
assert.deepEqual(widget(), [], "the new session starts with an empty card");
|
|
3697
4175
|
assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/);
|
|
@@ -3703,7 +4181,7 @@ test("the card follows the active session: after /new the earlier session's task
|
|
|
3703
4181
|
assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator");
|
|
3704
4182
|
overlay.handleInput("\x1b");
|
|
3705
4183
|
await opened;
|
|
3706
|
-
sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd };
|
|
4184
|
+
sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
|
|
3707
4185
|
await fire("session_start", ctx, { type: "session_start", reason: "resume" });
|
|
3708
4186
|
assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again");
|
|
3709
4187
|
});
|
|
@@ -3792,6 +4270,405 @@ test("session transport startup failure cleans the constructed Windows-capable t
|
|
|
3792
4270
|
assert.match((await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx)).content[0].text, /not ready/);
|
|
3793
4271
|
});
|
|
3794
4272
|
|
|
4273
|
+
test("registered task aliases persist independently of human names and curated state", async () => {
|
|
4274
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
4275
|
+
const profile = realpathSync(mkdtempSync(join(root, "aliases-runtime-")));
|
|
4276
|
+
runtime.deps.agentHome = profile;
|
|
4277
|
+
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/aliases.sock", createdAt: 1 };
|
|
4278
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
4279
|
+
runtime.deps.sessionTransport = {
|
|
4280
|
+
createRegistry: async () => registry,
|
|
4281
|
+
createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
|
|
4282
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
|
|
4283
|
+
};
|
|
4284
|
+
let name = "Human label";
|
|
4285
|
+
Object.assign(h.pi, { setSessionName: () => { throw new Error("named session must not be renamed"); } });
|
|
4286
|
+
let branch = h.entries;
|
|
4287
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => name, getBranch: () => branch });
|
|
4288
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4289
|
+
await h.fire("session_start", ctx);
|
|
4290
|
+
const tool = h.tools.get("orchestrator_session_id")!;
|
|
4291
|
+
const declare = (params: unknown = {}, context = ctx) => tool.execute("aliases", params, undefined, undefined, context);
|
|
4292
|
+
const aliases = () => readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
|
|
4293
|
+
const first = await declare({ subject: "Task A", state: { progress: "Starting", work: { area: "Auth" } } });
|
|
4294
|
+
const initial = h.entries.at(-1)!;
|
|
4295
|
+
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
|
|
4296
|
+
assert.match(first.content[0].text, /Current alias \(CURRENT TASK\): Task A/);
|
|
4297
|
+
assert.match(first.content[0].text, /Initial alias \(FIRST TASK\): Task A/);
|
|
4298
|
+
const recordedAt = readDiscovery(profile, listPresence(profile).entries[0])?.state?.recordedAt;
|
|
4299
|
+
const second = await declare({ subject: "Task B" });
|
|
4300
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.recordedAt, recordedAt);
|
|
4301
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.progress, "Starting");
|
|
4302
|
+
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
|
|
4303
|
+
assert.match(second.content[0].text, /Session name: Human label/);
|
|
4304
|
+
assert.equal((second.details.gentleAgents as { senderSessionId: string }).senderSessionId, "s1");
|
|
4305
|
+
assert.equal(listPresence(profile).entries[0].label, name);
|
|
4306
|
+
const list = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
|
|
4307
|
+
assert.match(list.content[0].text, /Task A/);
|
|
4308
|
+
assert.match(list.content[0].text, /Task B/);
|
|
4309
|
+
const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "s1" }, undefined, undefined, ctx);
|
|
4310
|
+
assert.deepEqual(JSON.parse(consult.content[0].text).snapshot.aliases, aliases());
|
|
4311
|
+
const filtered = await h.tools.get("orchestrator_list")!.execute("filter", { filter: { text: "Task B" } }, undefined, undefined, ctx);
|
|
4312
|
+
assert.equal(JSON.parse(filtered.content[0].text).matches[0].label, "Task B");
|
|
4313
|
+
name = "Human rename";
|
|
4314
|
+
await declare({ state: { progress: "Replacement" } });
|
|
4315
|
+
await declare({ state: null });
|
|
4316
|
+
const writes = h.entries.length;
|
|
4317
|
+
await declare();
|
|
4318
|
+
assert.equal(h.entries.length, writes);
|
|
4319
|
+
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
|
|
4320
|
+
await h.fire("session_start", ctx, { reason: "resume" });
|
|
4321
|
+
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task B" });
|
|
4322
|
+
branch = [initial];
|
|
4323
|
+
await h.fire("session_tree", ctx);
|
|
4324
|
+
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
|
|
4325
|
+
branch = [{ ...initial, data: { ...(initial.data as object), aliases: undefined } }];
|
|
4326
|
+
// An actual legacy envelope has no alias field, not a malformed undefined field.
|
|
4327
|
+
delete (branch[0].data as { aliases?: unknown }).aliases;
|
|
4328
|
+
await h.fire("session_start", ctx, { reason: "reload" });
|
|
4329
|
+
assert.equal(aliases(), undefined);
|
|
4330
|
+
await declare({ subject: "Legacy current" });
|
|
4331
|
+
assert.deepEqual(aliases(), { initialAlias: null, currentAlias: "Legacy current" });
|
|
4332
|
+
const replacement = fakeContext().ctx;
|
|
4333
|
+
await h.fire("session_start", replacement);
|
|
4334
|
+
assert.match((await declare({ subject: "Stale" }, ctx)).content[0].text, /not ready/);
|
|
4335
|
+
assert.equal(aliases(), undefined);
|
|
4336
|
+
assert.equal(runtime.spawned.length + h.sent.length + h.userMessages.length, 0);
|
|
4337
|
+
});
|
|
4338
|
+
|
|
4339
|
+
test("registered session identity declares subjects and refreshes canonical idle renames", async (t) => {
|
|
4340
|
+
const h = fakePi();
|
|
4341
|
+
const runtime = deps();
|
|
4342
|
+
const profile = realpathSync(mkdtempSync(join(root, "subject-runtime-")));
|
|
4343
|
+
runtime.deps.agentHome = profile;
|
|
4344
|
+
let ready = false;
|
|
4345
|
+
const registry = { list: async () => [], listActivations: async () => [] };
|
|
4346
|
+
runtime.deps.sessionTransport = {
|
|
4347
|
+
createRegistry: async () => registry,
|
|
4348
|
+
createListener: (_registry, sessionId) => ({ registry,
|
|
4349
|
+
record: { version: 1 as const, sessionId, endpoint: "/fixture/subject.sock", createdAt: 1 },
|
|
4350
|
+
start: async () => { ready = true; }, close: async () => {} }),
|
|
4351
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
|
|
4352
|
+
};
|
|
4353
|
+
const heartbeats: (() => void)[] = [];
|
|
4354
|
+
const interval = globalThis.setInterval;
|
|
4355
|
+
t.mock.method(globalThis, "setInterval", (callback: () => void, ms: number) => {
|
|
4356
|
+
if (ms === 5000) heartbeats.push(callback);
|
|
4357
|
+
return interval(callback, ms);
|
|
4358
|
+
});
|
|
4359
|
+
let name = "";
|
|
4360
|
+
const names: string[] = [];
|
|
4361
|
+
Object.assign(h.pi, { setSessionName: (value: string) => { name = value; names.push(value); } });
|
|
4362
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4363
|
+
const { ctx } = fakeContext();
|
|
4364
|
+
const registrations = [{ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId: "s1", root: cwd, evidence: "fixture" } }];
|
|
4365
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => name, getEntries: () => registrations });
|
|
4366
|
+
await h.fire("session_start", ctx);
|
|
4367
|
+
await eventually(() => ready, "subject transport ready");
|
|
4368
|
+
const tool = h.tools.get("orchestrator_session_id")!;
|
|
4369
|
+
const declare = (subject?: unknown, context = ctx) => tool.execute("id", { subject }, undefined, undefined, context);
|
|
4370
|
+
const initialWrites = h.entries.length;
|
|
4371
|
+
await assert.rejects(() => tool.execute("invalid-work", { subject: "No effect", state: { work: { topic: "Login" } } }, undefined, undefined, ctx), /invalid/);
|
|
4372
|
+
assert.equal(name, "");
|
|
4373
|
+
assert.equal(h.entries.length, initialWrites);
|
|
4374
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state, undefined);
|
|
4375
|
+
const result = await declare("\u001b[31m Fix\n auth\u202e ");
|
|
4376
|
+
assert.equal(name, "Fix auth");
|
|
4377
|
+
assert.match(result.content[0].text, /Active session ID: s1.*\n.*Fix auth/);
|
|
4378
|
+
assert.deepEqual(result.details.gentleAgents, { senderSessionId: "s1", alias: "Fix auth", sessionName: "Fix auth", initialAlias: "Fix auth", currentAlias: "Fix auth" });
|
|
4379
|
+
const before = listPresence(profile).entries[0]!;
|
|
4380
|
+
assert.equal(before.label, "Fix auth");
|
|
4381
|
+
assert.equal(readDiscovery(profile, before)?.state?.state, null, "no implicit summary");
|
|
4382
|
+
Object.assign(ctx.sessionManager, { getBranch: () => h.entries });
|
|
4383
|
+
const work = { area: "Auth", topic: "Login", tags: ["Review"], refs: [
|
|
4384
|
+
{ kind: "issue", repository: "github.com/Owner/Repo", id: "12" },
|
|
4385
|
+
] };
|
|
4386
|
+
await tool.execute("publish", { state: { objective: "Verify auth", decisions: "Advisory only", work } }, undefined, undefined, ctx);
|
|
4387
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
|
|
4388
|
+
assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.work, work);
|
|
4389
|
+
const published = h.entries.at(-1)!;
|
|
4390
|
+
const writes = h.entries.length;
|
|
4391
|
+
await assert.rejects(() => tool.execute("invalid", { subject: "No effect", state: { grant: "yes" } }, undefined, undefined, ctx), /invalid/);
|
|
4392
|
+
assert.equal(h.entries.length, writes);
|
|
4393
|
+
assert.equal(name, "Fix auth");
|
|
4394
|
+
await declare();
|
|
4395
|
+
assert.equal(h.entries.length, writes, "omission leaves notes unchanged");
|
|
4396
|
+
assert.equal(readDiscovery(profile, before)?.workspace, cwd);
|
|
4397
|
+
assert.equal(readDiscovery(profile, before)?.scope?.host.root, cwd);
|
|
4398
|
+
assert.equal(readDiscovery(profile, before)?.scope?.registered[0]?.root, cwd);
|
|
4399
|
+
name = "Human rename";
|
|
4400
|
+
heartbeats[0](); // Existing publisher heartbeat, without any task/model activity.
|
|
4401
|
+
const renamed = listPresence(profile).entries[0]!;
|
|
4402
|
+
assert.equal(renamed.label, name);
|
|
4403
|
+
assert.equal(renamed.generation, before.generation);
|
|
4404
|
+
assert.deepEqual(readActivity(profile, renamed).activity?.tasks, []);
|
|
4405
|
+
assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt);
|
|
4406
|
+
registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } });
|
|
4407
|
+
h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" });
|
|
4408
|
+
assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]);
|
|
4409
|
+
await declare("Do not overwrite");
|
|
4410
|
+
assert.deepEqual(names, ["Fix auth"]);
|
|
4411
|
+
assert.match((await declare()).content[0].text, /Human rename/);
|
|
4412
|
+
name = "";
|
|
4413
|
+
await declare("\u0000\u001b[31m");
|
|
4414
|
+
assert.equal(name, "", "control-only subject never names a session");
|
|
4415
|
+
await declare("😀".repeat(130));
|
|
4416
|
+
assert.equal(Array.from(name).length, 120);
|
|
4417
|
+
await tool.execute("withdraw", { state: null }, undefined, undefined, ctx);
|
|
4418
|
+
await h.fire("session_start", ctx, { reason: "reload" });
|
|
4419
|
+
await eventually(() => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state === null, "withdrawal reload");
|
|
4420
|
+
Object.assign(ctx.sessionManager, { getBranch: () => [published] });
|
|
4421
|
+
await h.fire("session_tree", ctx);
|
|
4422
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
|
|
4423
|
+
const sameId = fakeContext().ctx;
|
|
4424
|
+
Object.assign(sameId.sessionManager, { getSessionName: () => name, getBranch: () => [] });
|
|
4425
|
+
await h.fire("session_start", sameId, { reason: "resume" });
|
|
4426
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state, undefined);
|
|
4427
|
+
const replacement = fakeContext().ctx;
|
|
4428
|
+
Object.assign(replacement.sessionManager, { getSessionId: () => "s2", getSessionName: () => "Replacement" });
|
|
4429
|
+
await h.fire("session_start", replacement, { reason: "new" });
|
|
4430
|
+
heartbeats[0]();
|
|
4431
|
+
assert.equal(listPresence(profile).entries.length, 1);
|
|
4432
|
+
assert.equal(listPresence(profile).entries[0].label, "Replacement");
|
|
4433
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.host.root, cwd);
|
|
4434
|
+
const stale = await declare("Stale subject", ctx);
|
|
4435
|
+
assert.match(stale.content[0].text, /not ready/);
|
|
4436
|
+
assert.equal(names.length, 2);
|
|
4437
|
+
assert.equal(runtime.spawned.length, 0);
|
|
4438
|
+
assert.equal(h.sent.length, 0);
|
|
4439
|
+
assert.equal(h.userMessages.length, 0);
|
|
4440
|
+
});
|
|
4441
|
+
|
|
4442
|
+
test("registered orchestrator_list joins peer metadata without child launches or messages", async () => {
|
|
4443
|
+
const h = fakePi();
|
|
4444
|
+
const runtime = deps();
|
|
4445
|
+
const profile = realpathSync(mkdtempSync(join(root, "discovery-runtime-")));
|
|
4446
|
+
const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
|
|
4447
|
+
const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Auth review", activity: [] });
|
|
4448
|
+
try {
|
|
4449
|
+
const scope = new OrchestratorScopeCache(path => ({ root: path, commonDir: "/clone" })).project("/repo", [{ id: "child", cwd: "/repo-child" }], ["/repo"]);
|
|
4450
|
+
const tasks = [{ id: "child", label: "Check auth", status: "waiting", cwd: "/repo-child" },
|
|
4451
|
+
...Array.from({ length: 9 }, (_, i) => ({ id: `extra${i}`, label: `Extra ${i}`, status: "running", cwd: `/child/${i}` }))];
|
|
4452
|
+
publisher.updateDiscovery(peer, { workspace: "/repo", tasks, registered: Array.from({ length: 10 }, (_, i) => `/registered/${i}`), scope,
|
|
4453
|
+
state: { schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
|
|
4454
|
+
ownerReply: false, authority: "none", state: { work: { tasks: { child: { area: "Auth" }, extra8: { area: "Billing" } } } } },
|
|
4455
|
+
});
|
|
4456
|
+
let ready = false;
|
|
4457
|
+
let probes = 0;
|
|
4458
|
+
const resolveWorktree = runtime.deps.resolveWorktree;
|
|
4459
|
+
runtime.deps.resolveWorktree = (...args) => { probes++; return resolveWorktree(...args); };
|
|
4460
|
+
let gate: (() => Promise<void>) | undefined;
|
|
4461
|
+
let scans = 0;
|
|
4462
|
+
const registry = { list: async () => [], listActivations: async () => { scans++; await gate?.(); return [peer]; } };
|
|
4463
|
+
runtime.deps.agentHome = profile;
|
|
4464
|
+
runtime.deps.sessionTransport = {
|
|
4465
|
+
createRegistry: async () => registry,
|
|
4466
|
+
createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
|
|
4467
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
|
|
4468
|
+
};
|
|
4469
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4470
|
+
const { ctx } = fakeContext();
|
|
4471
|
+
await h.fire("session_start", ctx);
|
|
4472
|
+
await eventually(() => ready, "discovery transport ready");
|
|
4473
|
+
ctx.ui.select = async () => { throw new Error("metadata consultation must not ask for consent"); };
|
|
4474
|
+
const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
|
|
4475
|
+
assert.match(result.content[0].text, /peer.*Auth review.*recorded workspace: \/repo/);
|
|
4476
|
+
assert.match(result.content[0].text, /Check auth \[waiting\].*launch workspace: \/repo-child/);
|
|
4477
|
+
assert.match(result.content[0].text, /reachability is unknown/);
|
|
4478
|
+
assert.equal((result.details.gentleAgents as any).candidates[0].scope.host.root, "/repo");
|
|
4479
|
+
assert.equal((result.details.gentleAgents as any).candidates[0].scope.tasks[0].repository.root, "/repo-child");
|
|
4480
|
+
assert.match(result.content[0].text, /clone: [a-f0-9]{64}/);
|
|
4481
|
+
const cursor = (result.details.gentleAgents as any).candidates[0].catalog.cursor;
|
|
4482
|
+
const next = await h.tools.get("orchestrator_list")!.execute("next", { recipient_session_id: "peer", cursor }, undefined, undefined, ctx);
|
|
4483
|
+
assert.deepEqual((next.details.gentleAgents as any).candidates[0].catalog.tasks.map((t: any) => t.id), ["extra7", "extra8"]);
|
|
4484
|
+
assert.deepEqual((next.details.gentleAgents as any).candidates[0].catalog.registered, ["/registered/8", "/registered/9"]);
|
|
4485
|
+
assert.equal(h.userMessages.length, 0);
|
|
4486
|
+
const selectedWork = await h.tools.get("orchestrator_list")!.execute("selected-work", {
|
|
4487
|
+
filter: {}, recipient_session_id: "peer", cursor,
|
|
4488
|
+
}, undefined, undefined, ctx);
|
|
4489
|
+
assert.deepEqual(JSON.parse(selectedWork.content[0].text).matches.map((row: any) => row.taskId), ["extra8"]);
|
|
4490
|
+
const firstWorkPage = await h.tools.get("orchestrator_list")!.execute("first-work", {
|
|
4491
|
+
filter: {}, recipient_session_id: "peer",
|
|
4492
|
+
}, undefined, undefined, ctx);
|
|
4493
|
+
assert.deepEqual(JSON.parse(firstWorkPage.content[0].text).matches.map((row: any) => row.taskId), ["child"]);
|
|
4494
|
+
publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
|
|
4495
|
+
schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
|
|
4496
|
+
ownerReply: false, authority: "none", state: { progress: "Explicit summary", work: { area: "Auth" } },
|
|
4497
|
+
} });
|
|
4498
|
+
const noted = await h.tools.get("orchestrator_list")!.execute("note", { recipient_session_id: "peer" }, undefined, undefined, ctx);
|
|
4499
|
+
assert.match(noted.content[0].text, /Explicit summary/);
|
|
4500
|
+
assert.equal((noted.details.gentleAgents as any).candidates[0].state.recordedAt, 1);
|
|
4501
|
+
assert.deepEqual((noted.details.gentleAgents as any).candidates[0].state.state.work, { area: "Auth" });
|
|
4502
|
+
const list = h.tools.get("orchestrator_list")!;
|
|
4503
|
+
const workResult = await list.execute("work", { filter: {} }, undefined, undefined, ctx);
|
|
4504
|
+
const work = JSON.parse(workResult.content[0].text);
|
|
4505
|
+
assert.deepEqual(JSON.parse(JSON.stringify((workResult.details.gentleAgents as any).workSearch)), work);
|
|
4506
|
+
assert.equal(work.schema, 1);
|
|
4507
|
+
assert.deepEqual(work.matches[0].work, { area: "Auth" });
|
|
4508
|
+
assert.equal(work.coverage.exhaustive, false);
|
|
4509
|
+
assert.doesNotMatch(workResult.content[0].text, /Explicit summary|endpoint|activation|cursor|capabilities/);
|
|
4510
|
+
const defaultList = await list.execute("default", {}, undefined, undefined, ctx);
|
|
4511
|
+
assert.doesNotMatch(defaultList.content[0].text, /Explicit summary|"work"/);
|
|
4512
|
+
assert.equal((defaultList.details.gentleAgents as any).candidates[0].state, undefined);
|
|
4513
|
+
const beforeInvalid = scans;
|
|
4514
|
+
for (const invalid of [null, [], { unexpected: true }, { filter: null }, { filter: { topic: "Login" } },
|
|
4515
|
+
{ filter: { area: 1 } }, { filter: { area: "é".repeat(33) } }, { filter: { text: "bad\u0000text" } },
|
|
4516
|
+
{ filter: { ref: { kind: "issue", repository: "github.com/A/B", id: "01" } } },
|
|
4517
|
+
{ filter: { related_to: { session_id: "peer", extra: true } } }]) {
|
|
4518
|
+
await assert.rejects(list.execute("invalid", invalid, undefined, undefined, ctx), /Invalid orchestrator list/);
|
|
4519
|
+
}
|
|
4520
|
+
for (const args of [{ cursor: "x" }, { filter: {}, cursor: "x" }]) {
|
|
4521
|
+
const invalidCursor = await list.execute("cursor", args, undefined, undefined, ctx);
|
|
4522
|
+
assert.equal(invalidCursor.details.error, "invalid-cursor");
|
|
4523
|
+
assert.equal(invalidCursor.content[0].text, "Error: cursor requires recipient_session_id.");
|
|
4524
|
+
}
|
|
4525
|
+
assert.equal(scans, beforeInvalid, "invalid arguments never reach peer/profile discovery");
|
|
4526
|
+
const consult = h.tools.get("orchestrator_consult")!;
|
|
4527
|
+
const probesBeforeConsult = probes;
|
|
4528
|
+
const receipt = JSON.parse((await consult.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx)).content[0].text);
|
|
4529
|
+
assert.equal(receipt.snapshot.state.state.progress, "Explicit summary");
|
|
4530
|
+
assert.equal(receipt.snapshot.state.recordedAt, 1);
|
|
4531
|
+
assert.deepEqual(receipt.snapshot.state.state.work, { area: "Auth" });
|
|
4532
|
+
assert.equal(receipt.ownerReply, false);
|
|
4533
|
+
assert.equal(receipt.authority, "none");
|
|
4534
|
+
assert.equal(receipt.targetSessionId, "peer");
|
|
4535
|
+
assert.doesNotMatch(JSON.stringify(receipt), /endpoint|activation|prompt|thread/);
|
|
4536
|
+
for (const invalid of [{ recipient_session_id: "peer", kind: "reasoning" }, { recipient_session_id: "peer", question: "secret" }, {}]) {
|
|
4537
|
+
await assert.rejects(consult.execute("invalid", invalid, undefined, undefined, ctx), /Invalid metadata/);
|
|
4538
|
+
}
|
|
4539
|
+
assert.equal(probes, probesBeforeConsult);
|
|
4540
|
+
publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
|
|
4541
|
+
schema: 2, sessionId: "peer", cwd: "/repo", recordedAt: 2, source: "owner-curated",
|
|
4542
|
+
ownerReply: false, authority: "none", state: { work: { topic: "Missing area" } },
|
|
4543
|
+
} });
|
|
4544
|
+
const malformed = await h.tools.get("orchestrator_list")!.execute("malformed", { recipient_session_id: "peer" }, undefined, undefined, ctx);
|
|
4545
|
+
assert.equal((malformed.details.gentleAgents as any).candidates[0].sessionId, "peer");
|
|
4546
|
+
assert.equal((malformed.details.gentleAgents as any).candidates[0].state, undefined);
|
|
4547
|
+
assert.ok(readActivity(profile, listPresence(profile).entries[0]).activity);
|
|
4548
|
+
publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [] });
|
|
4549
|
+
const legacy = await h.tools.get("orchestrator_list")!.execute("legacy", {}, undefined, undefined, ctx);
|
|
4550
|
+
assert.match(legacy.content[0].text, /repository: unknown/);
|
|
4551
|
+
assert.equal((legacy.details.gentleAgents as any).candidates[0].sessionId, "peer");
|
|
4552
|
+
let release!: () => void;
|
|
4553
|
+
gate = () => new Promise<void>(resolve => { release = resolve; });
|
|
4554
|
+
const pending = consult.execute("pending", { recipient_session_id: "peer" }, undefined, undefined, ctx);
|
|
4555
|
+
await h.fire("session_shutdown", ctx);
|
|
4556
|
+
release();
|
|
4557
|
+
assert.equal(JSON.parse((await pending).content[0].text).status, "unavailable");
|
|
4558
|
+
assert.equal(runtime.spawned.length, 0);
|
|
4559
|
+
assert.equal(h.sent.length, 0);
|
|
4560
|
+
assert.equal(h.userMessages.length, 0);
|
|
4561
|
+
await h.fire("session_shutdown", ctx);
|
|
4562
|
+
} finally { publisher.dispose(); }
|
|
4563
|
+
});
|
|
4564
|
+
|
|
4565
|
+
test("registered reasoning and revocation use live SDK host and published source only", async () => {
|
|
4566
|
+
const h = fakePi(), runtime = deps();
|
|
4567
|
+
const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
|
|
4568
|
+
const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
|
|
4569
|
+
const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "Published peer", activity: [] });
|
|
4570
|
+
const publish = (progress = "Recorded") => publisher.updateDiscovery(peer, { workspace: "/repo", tasks: [], state: {
|
|
4571
|
+
schema: 1, sessionId: "peer", cwd: "/repo", recordedAt: 1, source: "owner-curated",
|
|
4572
|
+
ownerReply: false, authority: "none", state: { progress },
|
|
4573
|
+
} });
|
|
4574
|
+
publish(); let ready = false, dialogs = 0, calls = 0, choice = "Decline";
|
|
4575
|
+
let complete: (() => void) | undefined, hang = false;
|
|
4576
|
+
let peers = [peer];
|
|
4577
|
+
let duringDialog: (() => Promise<void> | void) | undefined;
|
|
4578
|
+
const registry = { list: async () => [], listActivations: async () => peers };
|
|
4579
|
+
runtime.deps.agentHome = profile;
|
|
4580
|
+
runtime.deps.sessionTransport = {
|
|
4581
|
+
createRegistry: async () => registry,
|
|
4582
|
+
createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
|
|
4583
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw Error("no owner message"); } }),
|
|
4584
|
+
};
|
|
4585
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4586
|
+
const { ctx } = fakeContext();
|
|
4587
|
+
const answer = { role: "assistant", api: "fixture", provider: "fixture", model: "local", timestamp: 1,
|
|
4588
|
+
stopReason: "stop", content: [{ type: "text", text: "Published advice" }], usage: {
|
|
4589
|
+
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
|
|
4590
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as AssistantMessage;
|
|
4591
|
+
Object.assign(ctx, { model: { id: "local", provider: "fixture", maxTokens: 1024 }, modelRegistry: {
|
|
4592
|
+
streamSimple(_model: unknown, context: { tools: unknown[]; messages: unknown[] }, options: { maxRetries: number }) {
|
|
4593
|
+
calls++; assert.deepEqual(context.tools, []); assert.equal(context.messages.length, 1);
|
|
4594
|
+
assert.equal(options.maxRetries, 0);
|
|
4595
|
+
const stream = createAssistantMessageEventStream();
|
|
4596
|
+
stream.result = hang ? () => new Promise(resolve => { complete = () => resolve(answer); }) : async () => answer;
|
|
4597
|
+
return stream;
|
|
4598
|
+
},
|
|
4599
|
+
} });
|
|
4600
|
+
// Test-simulated supported UI choices, NOT evidence of actual human approval.
|
|
4601
|
+
ctx.ui.select = async (title, options) => {
|
|
4602
|
+
dialogs++; assert.match(title, /Model-cost permission only/);
|
|
4603
|
+
assert.deepEqual(options, ["Allow once", "Allow this target + model for this session", "Decline"]);
|
|
4604
|
+
await duringDialog?.(); return choice;
|
|
4605
|
+
};
|
|
4606
|
+
try {
|
|
4607
|
+
await h.fire("session_start", ctx); await eventually(() => ready, "reasoning transport ready");
|
|
4608
|
+
const tool = h.tools.get("orchestrator_consult")!;
|
|
4609
|
+
const run = async (kind = "reasoning", patch = {}, caller = ctx, signal?: AbortSignal) => JSON.parse((await tool.execute("c",
|
|
4610
|
+
{ recipient_session_id: "peer", kind, ...(kind === "reasoning" ? { question: "What is published?" } : {}), ...patch }, signal, undefined, caller)).content[0].text);
|
|
4611
|
+
assert.equal((await run("metadata")).status, "available"); assert.deepEqual([dialogs, calls], [0, 0]);
|
|
4612
|
+
assert.equal((await run()).code, "permission-required"); assert.deepEqual([dialogs, calls], [1, 0]);
|
|
4613
|
+
choice = "Allow once"; const advice = await run();
|
|
4614
|
+
assert.equal(advice.source, "helper_advice"); assert.equal(advice.ownerReply, false); assert.equal(advice.authority, "none");
|
|
4615
|
+
assert.equal(advice.requestCaps.maxTokens, 512); assert.equal(advice.usage.totalTokens, 2);
|
|
4616
|
+
assert.deepEqual(advice.actualModel, { provider: "fixture", id: "local" }); assert.deepEqual([dialogs, calls], [2, 1]);
|
|
4617
|
+
choice = "Allow this target + model for this session"; await run();
|
|
4618
|
+
publisher.updateDiscovery(peer, { workspace: "/updated-public", tasks: [] }); await run();
|
|
4619
|
+
assert.deepEqual([dialogs, calls], [3, 3]);
|
|
4620
|
+
Object.assign(ctx, { hasUI: false });
|
|
4621
|
+
assert.equal((await run("revoke-reasoning")).status, "revoked");
|
|
4622
|
+
assert.deepEqual([dialogs, calls], [3, 3]); Object.assign(ctx, { hasUI: true });
|
|
4623
|
+
choice = "Decline"; assert.equal((await run()).code, "permission-required");
|
|
4624
|
+
for (const patch of [{ hasUI: false }, { mode: "print" }, { mode: "json" }]) {
|
|
4625
|
+
Object.assign(ctx, patch); assert.equal((await run()).code, "permission-required");
|
|
4626
|
+
}
|
|
4627
|
+
Object.assign(ctx, { hasUI: true, mode: "rpc" }); choice = "Allow once";
|
|
4628
|
+
assert.equal((await run()).status, "available");
|
|
4629
|
+
const before = [dialogs, calls];
|
|
4630
|
+
for (const args of [{ question: "implicit" }, { kind: "reasoning" }, { kind: "revoke-reasoning", cursor: "x" },
|
|
4631
|
+
{ kind: "metadata", question: "irrelevant" }, { kind: "reasoning", question: "é".repeat(513) },
|
|
4632
|
+
{ kind: null }, { kind: "unknown" }, { cursor: "x".repeat(1025) }, { unexpected: true }]) {
|
|
4633
|
+
await assert.rejects(tool.execute("bad", { recipient_session_id: "peer", ...args }, undefined, undefined, ctx), /Invalid metadata/);
|
|
4634
|
+
}
|
|
4635
|
+
assert.deepEqual([dialogs, calls], before);
|
|
4636
|
+
choice = "Allow this target + model for this session"; await run();
|
|
4637
|
+
for (const event of ["session_tree", "resources_discover", "session_before_switch", "session_before_fork", "session_before_tree", "model_select", "session_start"]) {
|
|
4638
|
+
await h.fire(event, ctx); choice = "Decline";
|
|
4639
|
+
assert.equal((await run()).code, "permission-required");
|
|
4640
|
+
choice = "Allow this target + model for this session"; await run();
|
|
4641
|
+
}
|
|
4642
|
+
await run("revoke-reasoning"); choice = "Allow once"; hang = true;
|
|
4643
|
+
for (const change of ["public", "activation", "unavailable", "private", "abort", "revoke"]) {
|
|
4644
|
+
publish(); peers = [peer]; const controller = new AbortController();
|
|
4645
|
+
const pending = run("reasoning", {}, ctx, controller.signal);
|
|
4646
|
+
await eventually(() => !!complete, "helper pending");
|
|
4647
|
+
if (change === "public") publish("Changed progress");
|
|
4648
|
+
if (change === "activation") peers = [{ ...peer, endpoint: "/fixture/replaced.sock" }];
|
|
4649
|
+
if (change === "unavailable") peers = [];
|
|
4650
|
+
if (change === "private") { publisher.update([]); publisher.refreshLabel(); }
|
|
4651
|
+
if (change === "abort") controller.abort();
|
|
4652
|
+
if (change === "revoke") await run("revoke-reasoning");
|
|
4653
|
+
complete!(); complete = undefined;
|
|
4654
|
+
const outcome = await pending;
|
|
4655
|
+
assert.equal(outcome.status, change === "private" ? "available" : "unavailable");
|
|
4656
|
+
}
|
|
4657
|
+
hang = false; peers = [peer];
|
|
4658
|
+
for (const change of [() => Object.assign(ctx, { model: { ...ctx.model! } }),
|
|
4659
|
+
() => publish("Changed after dialog"),
|
|
4660
|
+
() => h.fire("session_tree", ctx), () => run("revoke-reasoning"),
|
|
4661
|
+
() => h.fire("session_shutdown", ctx),
|
|
4662
|
+
() => h.fire("session_start", fakeContext().ctx, { reason: "new" })]) {
|
|
4663
|
+
await h.fire("session_start", ctx); await new Promise(resolve => setImmediate(resolve));
|
|
4664
|
+
publish(); duringDialog = async () => { await change(); }; const count = calls;
|
|
4665
|
+
assert.equal((await run()).status, "unavailable"); assert.equal(calls, count);
|
|
4666
|
+
}
|
|
4667
|
+
assert.equal((await run("metadata", {}, ctx)).status, "unavailable");
|
|
4668
|
+
assert.equal(runtime.spawned.length, 0); assert.equal(h.sent.length, 0); assert.equal(h.userMessages.length, 0);
|
|
4669
|
+
} finally { await h.fire("session_shutdown", ctx); publisher.dispose(); }
|
|
4670
|
+
});
|
|
4671
|
+
|
|
3795
4672
|
test("session transport adds host tools, forwards notifications, and closes on shutdown", async () => {
|
|
3796
4673
|
const h = fakePi();
|
|
3797
4674
|
const runtime = deps();
|
|
@@ -3799,7 +4676,7 @@ test("session transport adds host tools, forwards notifications, and closes on s
|
|
|
3799
4676
|
let listenerStarts = 0;
|
|
3800
4677
|
let listenerCloses = 0;
|
|
3801
4678
|
let clientCloses = 0;
|
|
3802
|
-
const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [] };
|
|
4679
|
+
const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [{ version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 }] };
|
|
3803
4680
|
runtime.deps.sessionTransport = {
|
|
3804
4681
|
createRegistry: async () => registry,
|
|
3805
4682
|
createListener: (_registry, _sessionId, received) => {
|
|
@@ -3818,10 +4695,20 @@ test("session transport adds host tools, forwards notifications, and closes on s
|
|
|
3818
4695
|
assert.ok(h.tools.has("orchestrator_send_message"));
|
|
3819
4696
|
assert.match((await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx)).content[0].text, /peer/);
|
|
3820
4697
|
assert.ok(callback, "listener receives the inbound callback");
|
|
4698
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "Integration" });
|
|
3821
4699
|
await callback!({ id: "message-1", senderSessionId: "peer", message: "\u001b[31mraw model content" });
|
|
3822
4700
|
assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
|
|
3823
4701
|
assert.match(String(h.sent.at(-1)?.message.content), /\u001b\[31mraw model content/);
|
|
3824
4702
|
assert.deepEqual(h.sent.at(-1)?.options, { deliverAs: "followUp", triggerTurn: true });
|
|
4703
|
+
const renderer = h.renderers.get("gentle-agents.orchestrator-message")!;
|
|
4704
|
+
const compact = renderer(h.sent.at(-1)!.message, { expanded: false }, plainTheme).render(80).join("\n");
|
|
4705
|
+
assert.match(compact, /Message received/);
|
|
4706
|
+
assert.match(compact, /🤖 Orchestrator → 🤖 Integration/);
|
|
4707
|
+
assert.match(compact, /raw model content/);
|
|
4708
|
+
assert.doesNotMatch(compact, /Session message from|correlation|message-1|\u001b/);
|
|
4709
|
+
const expanded = renderer(h.sent.at(-1)!.message, { expanded: true }, plainTheme).render(80).join("\n");
|
|
4710
|
+
assert.match(expanded, /message-1/);
|
|
4711
|
+
assert.match(expanded, /Sender session: peer/);
|
|
3825
4712
|
await h.fire("session_shutdown", ctx);
|
|
3826
4713
|
assert.equal(clientCloses, 1);
|
|
3827
4714
|
assert.equal(listenerCloses, 1);
|
|
@@ -3877,6 +4764,7 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3877
4764
|
const { ctx, dialogs } = fakeContext();
|
|
3878
4765
|
await h.fire("session_start", ctx);
|
|
3879
4766
|
await eventually(() => callbacks.length === 1, "initial transport callback registration");
|
|
4767
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "Backend" });
|
|
3880
4768
|
const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer", reason: "because the peer needs an update" }, undefined, undefined, ctx);
|
|
3881
4769
|
assert.deepEqual(dialogs, [
|
|
3882
4770
|
"select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta",
|
|
@@ -3884,6 +4772,20 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3884
4772
|
]);
|
|
3885
4773
|
assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
|
|
3886
4774
|
assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
|
|
4775
|
+
const tool = h.tools.get("orchestrator_send_message")!;
|
|
4776
|
+
assert.equal(tool.renderShell, "self", "do not nest the card in Pi's default tool box");
|
|
4777
|
+
const args = { message: "hello peer", reason: "because the peer needs an update" };
|
|
4778
|
+
const renderContext = { args, state: {}, expanded: false, isPartial: false };
|
|
4779
|
+
const call = (tool.renderCall as Function)(args, plainTheme, renderContext);
|
|
4780
|
+
const output = (tool.renderResult as Function)(result, { expanded: false }, plainTheme, renderContext);
|
|
4781
|
+
const compact = [...call.render(80), ...output.render(80)].join("\n");
|
|
4782
|
+
assert.match(compact, /Message queued/);
|
|
4783
|
+
assert.match(compact, /🤖 Backend → 🤖 Orchestrator/);
|
|
4784
|
+
assert.match(compact, /hello peer/);
|
|
4785
|
+
assert.doesNotMatch(compact, /accepted-1|Sender session:|Recipient session:|because the peer/);
|
|
4786
|
+
const expanded = (tool.renderResult as Function)(result, { expanded: true }, plainTheme, { ...renderContext, expanded: true }).render(80).join("\n");
|
|
4787
|
+
assert.match(expanded, /accepted-1/);
|
|
4788
|
+
assert.match(expanded, /not a delivery or read receipt/);
|
|
3887
4789
|
const original = callbacks[0]!;
|
|
3888
4790
|
(ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
|
|
3889
4791
|
await h.fire("session_start", ctx);
|
|
@@ -3893,6 +4795,17 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3893
4795
|
await h.fire("session_shutdown", ctx);
|
|
3894
4796
|
});
|
|
3895
4797
|
|
|
4798
|
+
test("all orchestrator tools own compact and expanded card rendering", () => {
|
|
4799
|
+
const h = fakePi();
|
|
4800
|
+
gentleAgents(h.pi, {}, deps().deps);
|
|
4801
|
+
for (const name of ["orchestrator_session_id", "orchestrator_consult", "orchestrator_list", "orchestrator_send_message"]) {
|
|
4802
|
+
const tool = h.tools.get(name)!;
|
|
4803
|
+
assert.equal(tool.renderShell, "self", name);
|
|
4804
|
+
assert.equal(typeof tool.renderCall, "function", name);
|
|
4805
|
+
assert.equal(typeof tool.renderResult, "function", name);
|
|
4806
|
+
}
|
|
4807
|
+
});
|
|
4808
|
+
|
|
3896
4809
|
// Issue #1364: user consent before cross-orchestrator communication
|
|
3897
4810
|
test("orchestrator_send_message requires consent on explicit recipient ID and sends nothing when denied", async () => {
|
|
3898
4811
|
const h = fakePi();
|
|
@@ -4143,10 +5056,166 @@ test("a background completion settling while the parent agent runs is delivered
|
|
|
4143
5056
|
await fire("session_shutdown", ctx);
|
|
4144
5057
|
});
|
|
4145
5058
|
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
5059
|
+
test("Bridge identity is durable, session-owned and registered once across session changes", async () => {
|
|
5060
|
+
const h = fakePi();
|
|
5061
|
+
const runtime = deps();
|
|
5062
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
5063
|
+
const { ctx } = fakeContext();
|
|
5064
|
+
ctx.sessionManager.getEntries = () => h.entries as unknown as ReturnType<ExtensionContext["sessionManager"]["getEntries"]>;
|
|
5065
|
+
await h.fire("session_start", ctx);
|
|
5066
|
+
await h.tools.get("subagent_run")!.execute("one", { agent: "explore", task: "Wake identity", mode: "background" }, undefined, undefined, ctx);
|
|
5067
|
+
await tick();
|
|
5068
|
+
runtime.children[0].message({ id: "q1", kind: "query", message: "Question" });
|
|
5069
|
+
await tick();
|
|
5070
|
+
const wake = String(h.userMessages[0].content);
|
|
5071
|
+
const transform = h.transformers[0];
|
|
5072
|
+
const context = { messageType: "user" as const, isStreaming: false, availableWidth: 80 };
|
|
5073
|
+
assert.equal(transform(wake, context), "");
|
|
5074
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-agents.wake-identity").length, 1);
|
|
5075
|
+
await h.fire("session_start", ctx, { reason: "reload" });
|
|
5076
|
+
assert.equal(transform(wake, context), "", "reload reconstructs ownership");
|
|
5077
|
+
ctx.sessionManager.getSessionId = () => "other-session";
|
|
5078
|
+
await h.fire("session_start", ctx, { reason: "new" });
|
|
5079
|
+
assert.equal(transform(wake, context), wake, "another session cannot claim the old identity");
|
|
5080
|
+
ctx.sessionManager.getSessionId = () => "s1";
|
|
5081
|
+
await h.fire("session_start", ctx, { reason: "resume" });
|
|
5082
|
+
assert.equal(transform(wake, context), "");
|
|
5083
|
+
assert.equal(h.transformers.length, 1, "session changes do not register again");
|
|
5084
|
+
assert.equal(transform(`Quoted: ${wake}`, context), `Quoted: ${wake}`);
|
|
5085
|
+
assert.equal(transform(` ${wake}`, context), ` ${wake}`);
|
|
5086
|
+
await h.fire("session_shutdown", ctx);
|
|
5087
|
+
assert.equal(transform(wake, context), wake, "shutdown releases the old owner");
|
|
5088
|
+
});
|
|
5089
|
+
|
|
5090
|
+
for (const failure of ["missing-api", "persistence", "warning-ui"] as const) {
|
|
5091
|
+
test(`Bridge ${failure} fallback preserves continuation and flags visible wake`, async () => {
|
|
5092
|
+
const h = fakePi();
|
|
5093
|
+
const runtime = deps();
|
|
5094
|
+
if (failure !== "persistence") delete (h.pi as Partial<ExtensionAPI>).registerMarkdownTransformer;
|
|
5095
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
5096
|
+
const { ctx, dialogs } = fakeContext();
|
|
5097
|
+
if (failure === "warning-ui") ctx.ui.notify = () => { throw new Error("UI unavailable"); };
|
|
5098
|
+
await h.fire("session_start", ctx);
|
|
5099
|
+
if (failure === "persistence") h.pi.appendEntry = () => { throw new Error("Persistence unavailable"); };
|
|
5100
|
+
await h.tools.get("subagent_run")!.execute("fallback", { agent: "explore", task: "Fallback", mode: "background" }, undefined, undefined, ctx);
|
|
5101
|
+
await tick();
|
|
5102
|
+
runtime.children[0].message({ id: "q1", kind: "query", message: "Question" });
|
|
5103
|
+
await tick();
|
|
5104
|
+
assert.equal(h.userMessages.length, 1);
|
|
5105
|
+
assert.equal(h.sent.length, 1, "payload is stored once despite compatibility fallback");
|
|
5106
|
+
assert.doesNotMatch(String(h.userMessages[0].content), /gentle-agents wake:/);
|
|
5107
|
+
if (failure !== "warning-ui") assert.ok(dialogs.some(message => message.includes("cannot hide Claude Bridge")));
|
|
5108
|
+
});
|
|
5109
|
+
}
|
|
5110
|
+
|
|
5111
|
+
for (const provider of ["openai", "anthropic", "custom-extension", undefined, "claude-bridge"]) {
|
|
5112
|
+
for (const kind of ["completion", "query"] as const) {
|
|
5113
|
+
test(`idle ${kind} wake uses the selected ${provider ?? "missing"} provider route`, async () => {
|
|
5114
|
+
const h = fakePi();
|
|
5115
|
+
h.setProvider(provider);
|
|
5116
|
+
const harness = deps();
|
|
5117
|
+
gentleAgents(h.pi, {}, harness.deps);
|
|
5118
|
+
const { ctx } = fakeContext();
|
|
5119
|
+
await h.fire("session_start", ctx);
|
|
5120
|
+
await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Route wake", mode: "background" }, undefined, undefined, ctx);
|
|
5121
|
+
await tick();
|
|
5122
|
+
if (kind === "query") harness.children[0].message({ id: "q1", kind: "query", message: "Child payload" });
|
|
5123
|
+
else {
|
|
5124
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Child payload" }] }] });
|
|
5125
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5126
|
+
}
|
|
5127
|
+
await tick();
|
|
5128
|
+
assert.deepEqual(h.sent[0]!.options, { triggerTurn: false });
|
|
5129
|
+
assert.match(String(h.sent[0]!.message.content), /Child payload/);
|
|
5130
|
+
if (provider === "claude-bridge") {
|
|
5131
|
+
assert.equal(h.sent.length, 1);
|
|
5132
|
+
assert.equal(h.userMessages.length, 1);
|
|
5133
|
+
assert.match(String(h.userMessages[0].content), /\[gentle-agents wake: [0-9a-f-]+\]$/);
|
|
5134
|
+
assert.deepEqual(h.userMessages[0].options, { deliverAs: "steer" });
|
|
5135
|
+
assert.equal(h.transformers[0](String(h.userMessages[0].content), { messageType: "user", isStreaming: false, availableWidth: 80 }), "");
|
|
5136
|
+
assert.equal(h.delivery.at(-1), "user");
|
|
5137
|
+
} else {
|
|
5138
|
+
assert.equal(h.userMessages.length, 0, "native and unclassified providers never receive a synthetic user turn");
|
|
5139
|
+
assert.equal(h.sent.length, 2, "the stored payload still gets a continuation turn");
|
|
5140
|
+
assert.deepEqual(h.sent[1]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
5141
|
+
assert.equal(h.sent[1]!.message.display, false);
|
|
5142
|
+
assert.equal(h.sent[1]!.message.customType, "gentle-agents.wake");
|
|
5143
|
+
assert.equal(h.sent[1]!.message.content, "Review the delivered subagent output and continue.");
|
|
5144
|
+
assert.deepEqual(h.delivery, [`custom:gentle-agents.${kind === "query" ? "message" : "result"}`, "custom:gentle-agents.wake"]);
|
|
5145
|
+
}
|
|
5146
|
+
await h.fire("session_shutdown", ctx);
|
|
5147
|
+
});
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
|
|
5151
|
+
for (const [from, to] of [["openai", "claude-bridge"], ["claude-bridge", "openai"]]) {
|
|
5152
|
+
test(`wake reads the live provider after storage: ${from} to ${to}`, async () => {
|
|
5153
|
+
const h = fakePi(from);
|
|
5154
|
+
const harness = deps();
|
|
5155
|
+
gentleAgents(h.pi, {}, harness.deps);
|
|
5156
|
+
const { ctx } = fakeContext();
|
|
5157
|
+
await h.fire("session_start", ctx);
|
|
5158
|
+
await h.tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Switch before dispatch", mode: "background" }, undefined, undefined, ctx);
|
|
5159
|
+
await tick();
|
|
5160
|
+
const sendMessage = h.pi.sendMessage;
|
|
5161
|
+
Object.assign(h.pi, { sendMessage: (...args: Parameters<ExtensionAPI["sendMessage"]>) => {
|
|
5162
|
+
sendMessage(...args);
|
|
5163
|
+
// Storage happens synchronously, dispatch in the queued microtask.
|
|
5164
|
+
if (args[0].customType === "gentle-agents.message") h.setProvider(to);
|
|
5165
|
+
} });
|
|
5166
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Switch?" });
|
|
5167
|
+
await tick();
|
|
5168
|
+
assert.equal(h.userMessages.length, to === "claude-bridge" ? 1 : 0);
|
|
5169
|
+
assert.equal(h.sent.filter((entry) => entry.message.customType === "gentle-agents.wake").length, to === "openai" ? 1 : 0);
|
|
5170
|
+
await h.fire("session_shutdown", ctx);
|
|
5171
|
+
});
|
|
5172
|
+
}
|
|
5173
|
+
|
|
5174
|
+
for (const boundary of ["session_compact", "session_compact_failed"] as const) {
|
|
5175
|
+
test(`native wake preserves holds, coalescing and grace after ${boundary}`, async () => {
|
|
5176
|
+
const h = fakePi("openai");
|
|
5177
|
+
const harness = deps();
|
|
5178
|
+
const timers = recordTimers(harness.deps);
|
|
5179
|
+
gentleAgents(h.pi, {}, harness.deps);
|
|
5180
|
+
const { ctx } = fakeContext();
|
|
5181
|
+
await h.fire("session_start", ctx);
|
|
5182
|
+
for (const task of ["Ask", "Finish"]) await h.tools.get("subagent_run")!.execute(task, { agent: "explore", task, mode: "background" }, undefined, undefined, ctx);
|
|
5183
|
+
await tick();
|
|
5184
|
+
h.setIdle(false);
|
|
5185
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Held question" });
|
|
5186
|
+
harness.children[1].emit({ type: "agent_settled" });
|
|
5187
|
+
await tick();
|
|
5188
|
+
assert.equal(h.sent.length, 0);
|
|
5189
|
+
await h.fire(boundary, ctx);
|
|
5190
|
+
assert.equal(h.sent.length, 0);
|
|
5191
|
+
h.setIdle(true);
|
|
5192
|
+
assert.equal(timers.run(0), 1);
|
|
5193
|
+
await tick();
|
|
5194
|
+
assert.deepEqual(h.delivery, ["custom:gentle-agents.message", "custom:gentle-agents.result", "custom:gentle-agents.wake"]);
|
|
5195
|
+
assert.deepEqual(h.sent.map((entry) => entry.options), [{ triggerTurn: false }, { triggerTurn: false }, { deliverAs: "steer", triggerTurn: true }]);
|
|
5196
|
+
const expireWakeGrace = timers.takeLast(PARENT_WAKE_GRACE_MS);
|
|
5197
|
+
harness.children[0].message({ id: "q2", kind: "query", message: "Later question" });
|
|
5198
|
+
await tick();
|
|
5199
|
+
assert.equal(h.sent.length, 4, "content arriving in the grace window shares the pending wake");
|
|
5200
|
+
expireWakeGrace();
|
|
5201
|
+
await tick();
|
|
5202
|
+
assert.equal(h.sent.length, 5, "an unstarted wake cannot suppress new content beyond grace");
|
|
5203
|
+
assert.equal(h.sent[4]!.message.display, false);
|
|
5204
|
+
assert.deepEqual(h.sent[4]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
5205
|
+
await h.fire("agent_start", ctx);
|
|
5206
|
+
harness.children[0].message({ id: "q3", kind: "query", message: "Busy question" });
|
|
5207
|
+
await tick();
|
|
5208
|
+
assert.equal(h.sent.length, 5, "busy content stays queued until the turn boundary");
|
|
5209
|
+
await h.fire("turn_end", ctx);
|
|
5210
|
+
assert.equal(h.sent.at(-1)!.message.customType, "gentle-agents.message");
|
|
5211
|
+
assert.deepEqual(h.sent.at(-1)!.options, { deliverAs: "steer", triggerTurn: true }, "busy native delivery keeps the original steering route");
|
|
5212
|
+
assert.equal(h.userMessages.length, 0);
|
|
5213
|
+
await h.fire("session_shutdown", ctx);
|
|
5214
|
+
});
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
// Bridge custom-message turns skip the prompt lifecycle needed for capture.
|
|
5218
|
+
// Store the structured result durably and wake the bridge by a user message.
|
|
4150
5219
|
test("an idle parent stores the structured completion and is woken through the normal prompt lifecycle", async () => {
|
|
4151
5220
|
const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
|
|
4152
5221
|
const harness = deps();
|
|
@@ -4390,7 +5459,7 @@ test("a stale parent context fails closed: child completions and notifications a
|
|
|
4390
5459
|
await fire("session_shutdown", ctx);
|
|
4391
5460
|
});
|
|
4392
5461
|
|
|
4393
|
-
test("a completion held past the stale window
|
|
5462
|
+
test("a completion held past the stale window keeps its report out of the conversation and shows the human a stale card", async () => {
|
|
4394
5463
|
const { pi, tools, fire, sent, entries, entryRenderers } = fakePi();
|
|
4395
5464
|
const harness = deps();
|
|
4396
5465
|
let clock = 1000;
|
|
@@ -4419,41 +5488,206 @@ test("a completion held past the stale window becomes transcript-only content an
|
|
|
4419
5488
|
await fire("session_shutdown", ctx);
|
|
4420
5489
|
});
|
|
4421
5490
|
|
|
4422
|
-
|
|
5491
|
+
// Issue #1821/#1092: the parent is told never to poll, so an unread stale
|
|
5492
|
+
// completion must still tell the model a result is waiting, without replaying
|
|
5493
|
+
// the report itself.
|
|
5494
|
+
test("an unread completion held past the stale window steers a compact notice into the running parent", async () => {
|
|
4423
5495
|
const { pi, tools, fire, sent } = fakePi();
|
|
4424
5496
|
const harness = deps();
|
|
5497
|
+
let clock = 1000;
|
|
5498
|
+
harness.deps.now = () => clock;
|
|
4425
5499
|
gentleAgents(pi, {}, harness.deps);
|
|
4426
5500
|
const { ctx } = fakeContext();
|
|
4427
5501
|
await fire("session_start", ctx);
|
|
4428
|
-
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "
|
|
5502
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long tool call", mode: "background" }, undefined, undefined, ctx);
|
|
4429
5503
|
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
4430
5504
|
await tick();
|
|
4431
5505
|
await fire("agent_start", ctx);
|
|
4432
|
-
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "
|
|
5506
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
|
|
4433
5507
|
harness.children[0].emit({ type: "agent_settled" });
|
|
4434
5508
|
await tick();
|
|
5509
|
+
clock += STALE_COMPLETION_MS + 1_000;
|
|
5510
|
+
await fire("turn_end", ctx);
|
|
5511
|
+
const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice");
|
|
5512
|
+
assert.equal(notices.length, 1, "the model is told once that an unread result is waiting");
|
|
5513
|
+
assert.deepEqual(notices[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
5514
|
+
const content = String(notices[0]!.message.content);
|
|
5515
|
+
assert.match(content, new RegExp(id));
|
|
5516
|
+
assert.match(content, /subagent_result/);
|
|
5517
|
+
assert.doesNotMatch(content, /Late answer\./, "the stale report itself is never replayed");
|
|
4435
5518
|
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
4436
|
-
assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
|
|
4437
5519
|
await fire("turn_end", ctx);
|
|
4438
|
-
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.
|
|
5520
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice").length, 1, "the notice is sent at most once");
|
|
4439
5521
|
await fire("session_shutdown", ctx);
|
|
4440
5522
|
});
|
|
4441
5523
|
|
|
4442
|
-
test("
|
|
4443
|
-
const { pi, tools, fire, sent } = fakePi();
|
|
5524
|
+
test("an unread stale completion flushed at an idle boundary stores its notice and wakes the parent", async () => {
|
|
5525
|
+
const { pi, tools, fire, sent, userMessages, setIdle } = fakePi();
|
|
4444
5526
|
const harness = deps();
|
|
5527
|
+
let clock = 1000;
|
|
5528
|
+
harness.deps.now = () => clock;
|
|
4445
5529
|
gentleAgents(pi, {}, harness.deps);
|
|
4446
5530
|
const { ctx } = fakeContext();
|
|
4447
5531
|
await fire("session_start", ctx);
|
|
4448
|
-
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "
|
|
5532
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ends idle", mode: "background" }, undefined, undefined, ctx);
|
|
4449
5533
|
await tick();
|
|
4450
5534
|
await fire("agent_start", ctx);
|
|
4451
|
-
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "
|
|
5535
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
|
|
4452
5536
|
harness.children[0].emit({ type: "agent_settled" });
|
|
4453
5537
|
await tick();
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
await fire("
|
|
5538
|
+
clock += STALE_COMPLETION_MS + 1_000;
|
|
5539
|
+
setIdle(true);
|
|
5540
|
+
await fire("agent_end", ctx);
|
|
5541
|
+
await tick();
|
|
5542
|
+
const notices = sent.filter((entry) => entry.message.customType === "gentle-agents.stale-notice");
|
|
5543
|
+
assert.equal(notices.length, 1);
|
|
5544
|
+
assert.deepEqual(notices[0]!.options, { triggerTurn: false });
|
|
5545
|
+
assert.equal(userMessages.length, 1, "an idle parent is woken instead of waiting forever");
|
|
5546
|
+
await fire("session_shutdown", ctx);
|
|
5547
|
+
});
|
|
5548
|
+
|
|
5549
|
+
test("a completion whose forwarding throws stays queued and is delivered at the next boundary", async () => {
|
|
5550
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
5551
|
+
const harness = deps();
|
|
5552
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5553
|
+
const { ctx } = fakeContext();
|
|
5554
|
+
await fire("session_start", ctx);
|
|
5555
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Flaky host", mode: "background" }, undefined, undefined, ctx);
|
|
5556
|
+
await tick();
|
|
5557
|
+
await fire("agent_start", ctx);
|
|
5558
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Retried answer." }] }] });
|
|
5559
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5560
|
+
await tick();
|
|
5561
|
+
const sendMessage = pi.sendMessage;
|
|
5562
|
+
Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
|
|
5563
|
+
await fire("turn_end", ctx);
|
|
5564
|
+
assert.equal(sent.length, 0);
|
|
5565
|
+
Object.assign(pi, { sendMessage });
|
|
5566
|
+
await fire("turn_end", ctx);
|
|
5567
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
5568
|
+
assert.equal(results.length, 1, "a failed forward is retried, not lost");
|
|
5569
|
+
assert.match(String(results[0]!.message.content), /Retried answer\./);
|
|
5570
|
+
await fire("turn_end", ctx);
|
|
5571
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a delivered completion is never sent twice");
|
|
5572
|
+
await fire("session_shutdown", ctx);
|
|
5573
|
+
});
|
|
5574
|
+
|
|
5575
|
+
test("a completion whose forwarding throws while the parent is idle is retried after the bounded grace", async () => {
|
|
5576
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
5577
|
+
const harness = deps();
|
|
5578
|
+
const timers = recordTimers(harness.deps);
|
|
5579
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5580
|
+
const { ctx } = fakeContext();
|
|
5581
|
+
await fire("session_start", ctx);
|
|
5582
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle flaky host", mode: "background" }, undefined, undefined, ctx);
|
|
5583
|
+
await tick();
|
|
5584
|
+
const sendMessage = pi.sendMessage;
|
|
5585
|
+
Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
|
|
5586
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Idle retried answer." }] }] });
|
|
5587
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5588
|
+
await tick();
|
|
5589
|
+
assert.equal(sent.length, 0);
|
|
5590
|
+
assert.equal(userMessages.length, 0);
|
|
5591
|
+
Object.assign(pi, { sendMessage });
|
|
5592
|
+
// No turn boundary is coming for an idle parent, so the retry is a timer.
|
|
5593
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry flush is armed");
|
|
5594
|
+
await tick();
|
|
5595
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
5596
|
+
assert.equal(results.length, 1, "the requeued completion reaches the idle parent");
|
|
5597
|
+
assert.match(String(results[0]!.message.content), /Idle retried answer\./);
|
|
5598
|
+
assert.equal(userMessages.length, 1, "and the idle parent is woken for it");
|
|
5599
|
+
// Only the dispatched wake's own grace remains; expiring it resends nothing.
|
|
5600
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
|
|
5601
|
+
await tick();
|
|
5602
|
+
assert.equal(sent.length, 1, "a successful retry never delivers the completion twice");
|
|
5603
|
+
assert.equal(userMessages.length, 1);
|
|
5604
|
+
await fire("session_shutdown", ctx);
|
|
5605
|
+
});
|
|
5606
|
+
|
|
5607
|
+
test("retries for a failing idle delivery stop after the bounded limit", async () => {
|
|
5608
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
5609
|
+
const harness = deps();
|
|
5610
|
+
const timers = recordTimers(harness.deps);
|
|
5611
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5612
|
+
const { ctx } = fakeContext();
|
|
5613
|
+
await fire("session_start", ctx);
|
|
5614
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Broken host", mode: "background" }, undefined, undefined, ctx);
|
|
5615
|
+
await tick();
|
|
5616
|
+
Object.assign(pi, { sendUserMessage: () => { throw new Error("host rejected prompt"); } });
|
|
5617
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
5618
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5619
|
+
await tick();
|
|
5620
|
+
let retries = 0;
|
|
5621
|
+
while (timers.run(PARENT_WAKE_GRACE_MS) > 0) {
|
|
5622
|
+
retries += 1;
|
|
5623
|
+
await tick();
|
|
5624
|
+
assert.ok(retries <= 3, "a permanently failing wake never retries forever");
|
|
5625
|
+
}
|
|
5626
|
+
assert.equal(retries, 3);
|
|
5627
|
+
assert.equal(sent.length, 1, "retries never store the completion twice");
|
|
5628
|
+
assert.equal(userMessages.length, 0);
|
|
5629
|
+
await fire("session_shutdown", ctx);
|
|
5630
|
+
});
|
|
5631
|
+
|
|
5632
|
+
test("a wake that throws stays owed and is retried after the bounded grace", async () => {
|
|
5633
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
5634
|
+
const harness = deps();
|
|
5635
|
+
const timers = recordTimers(harness.deps);
|
|
5636
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5637
|
+
const { ctx } = fakeContext();
|
|
5638
|
+
await fire("session_start", ctx);
|
|
5639
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Lost wake", mode: "background" }, undefined, undefined, ctx);
|
|
5640
|
+
await tick();
|
|
5641
|
+
const sendUserMessage = pi.sendUserMessage;
|
|
5642
|
+
Object.assign(pi, { sendUserMessage: () => { throw new Error("host rejected prompt"); } });
|
|
5643
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
5644
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5645
|
+
await tick();
|
|
5646
|
+
assert.equal(sent.length, 1, "the completion is stored before the wake fails");
|
|
5647
|
+
assert.equal(userMessages.length, 0);
|
|
5648
|
+
Object.assign(pi, { sendUserMessage });
|
|
5649
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry is armed");
|
|
5650
|
+
await tick();
|
|
5651
|
+
assert.equal(userMessages.length, 1, "the idle parent is finally woken");
|
|
5652
|
+
assert.equal(sent.length, 1, "the retry wakes without storing the completion again");
|
|
5653
|
+
await fire("session_shutdown", ctx);
|
|
5654
|
+
});
|
|
5655
|
+
|
|
5656
|
+
test("a completion the parent already pulled is dropped silently at the next turn end", async () => {
|
|
5657
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
5658
|
+
const harness = deps();
|
|
5659
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5660
|
+
const { ctx } = fakeContext();
|
|
5661
|
+
await fire("session_start", ctx);
|
|
5662
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled early", mode: "background" }, undefined, undefined, ctx);
|
|
5663
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
5664
|
+
await tick();
|
|
5665
|
+
await fire("agent_start", ctx);
|
|
5666
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] });
|
|
5667
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5668
|
+
await tick();
|
|
5669
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
5670
|
+
assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
|
|
5671
|
+
await fire("turn_end", ctx);
|
|
5672
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a consumed completion is dropped instead of replayed");
|
|
5673
|
+
await fire("session_shutdown", ctx);
|
|
5674
|
+
});
|
|
5675
|
+
|
|
5676
|
+
test("a session restart never replays a completion still pending from before it", async () => {
|
|
5677
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
5678
|
+
const harness = deps();
|
|
5679
|
+
gentleAgents(pi, {}, harness.deps);
|
|
5680
|
+
const { ctx } = fakeContext();
|
|
5681
|
+
await fire("session_start", ctx);
|
|
5682
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Restarted", mode: "background" }, undefined, undefined, ctx);
|
|
5683
|
+
await tick();
|
|
5684
|
+
await fire("agent_start", ctx);
|
|
5685
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Unclaimed answer." }] }] });
|
|
5686
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
5687
|
+
await tick();
|
|
5688
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
5689
|
+
await fire("session_start", ctx, { reason: "resume" });
|
|
5690
|
+
await fire("turn_end", ctx);
|
|
4457
5691
|
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a resumed session starts with an empty completion queue");
|
|
4458
5692
|
await fire("session_shutdown", ctx);
|
|
4459
5693
|
});
|
|
@@ -4660,7 +5894,10 @@ test("issue #1162: task-mode subagent_run includes question directly in waiting
|
|
|
4660
5894
|
test("children receive context and safety extensions, and missing files are omitted", async () => {
|
|
4661
5895
|
const expected = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-context.ts");
|
|
4662
5896
|
const safety = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-safety.ts");
|
|
4663
|
-
|
|
5897
|
+
// gentle-shell#1731 T32: the nan provider is registered by a gentle-pi
|
|
5898
|
+
// extension, so a child routed to nan/* could not resolve its model.
|
|
5899
|
+
const nanProvider = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "nan-provider.ts");
|
|
5900
|
+
assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety), resolve(nanProvider)]);
|
|
4664
5901
|
assert.deepEqual(childContextExtensionPaths(() => false), [], "a missing extension file fails safe to no --extension");
|
|
4665
5902
|
const extensionArguments = (args: string[]) => args.filter((_, index) => args[index - 1] === "--extension");
|
|
4666
5903
|
for (const scenario of ["present", "missing"] as const) {
|
|
@@ -4674,10 +5911,592 @@ test("children receive context and safety extensions, and missing files are omit
|
|
|
4674
5911
|
await h.tools.get("subagent_run")!.execute(`child-context-${scenario}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
4675
5912
|
await tick();
|
|
4676
5913
|
assert.equal(runtime.spawned.length, 1);
|
|
4677
|
-
assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety)] : []);
|
|
5914
|
+
assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety), resolve(nanProvider)] : []);
|
|
5915
|
+
} finally {
|
|
5916
|
+
await h.fire("session_shutdown", ctx);
|
|
5917
|
+
await tick();
|
|
5918
|
+
}
|
|
5919
|
+
}
|
|
5920
|
+
});
|
|
5921
|
+
|
|
5922
|
+
// gentle-shell#1713 (review R3-001): prove the subagent_continue wiring end to
|
|
5923
|
+
// end, not only the helper. A writer follow-up without its own section reaches
|
|
5924
|
+
// the child with the surfaces its original launch was admitted with.
|
|
5925
|
+
test("a writer continuation without its own section inherits the admitted surfaces end to end", async () => {
|
|
5926
|
+
const h = fakePi();
|
|
5927
|
+
const runtime = deps();
|
|
5928
|
+
const profile = mkdtempSync(join(tmpdir(), "gentle-agents-continue-"));
|
|
5929
|
+
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
5930
|
+
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
5931
|
+
writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
|
|
5932
|
+
const env: NodeJS.ProcessEnv = {};
|
|
5933
|
+
gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile });
|
|
5934
|
+
const { ctx } = fakeContext();
|
|
5935
|
+
await h.fire("session_start", ctx);
|
|
5936
|
+
const launched = h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task: "Do T2.\n\n## Allowed edit surfaces\n- src/app.ts\n- `docs/with space.md`\n\n## Return\nReport", mode: "task" }, undefined, undefined, ctx);
|
|
5937
|
+
await tick();
|
|
5938
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "partial" }] }] });
|
|
5939
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
5940
|
+
const taskId = ((await launched).details.gentleAgents as { taskId: string }).taskId;
|
|
5941
|
+
const continued = h.tools.get("subagent_continue")!.execute("follow", { task_id: taskId, prompt: "Continue with the remaining specs.", mode: "task" }, undefined, undefined, ctx);
|
|
5942
|
+
await tick();
|
|
5943
|
+
assert.equal(runtime.children.length, 2, "the continuation is admitted instead of rejected");
|
|
5944
|
+
const prompt = String(runtime.children[1].written.find(frame => typeof frame.message === "string")?.message);
|
|
5945
|
+
assert.match(prompt, /^Continue with the remaining specs\.\n\n## Allowed edit surfaces\n`docs\/with space\.md`\n`src\/app\.ts`\n/);
|
|
5946
|
+
runtime.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
5947
|
+
runtime.children[1].emit({ type: "agent_settled" });
|
|
5948
|
+
await continued;
|
|
5949
|
+
await h.fire("session_shutdown", ctx);
|
|
5950
|
+
rmSync(profile, TEST_DIR_REMOVAL);
|
|
5951
|
+
});
|
|
5952
|
+
|
|
5953
|
+
// #1690: the launcher's injection signal in the host env replaces the curated
|
|
5954
|
+
// child entries; without a valid signal children keep the curated entries.
|
|
5955
|
+
test("children receive the launcher's package injection instead of the curated entries", async () => {
|
|
5956
|
+
const curated = ["/curated/child-context.ts", "/curated/child-safety.ts"];
|
|
5957
|
+
const takeover = ["/agent/npm/node_modules/other", "/agent/extensions/a b.ts", "/pkg"];
|
|
5958
|
+
const scenarios: Array<{ name: string; value: string | undefined; expected: string[] }> = [
|
|
5959
|
+
{ name: "takeover", value: encodeChildPackageInjection({ noExtensions: true, extensionPaths: takeover }), expected: ["--no-extensions", ...takeover.flatMap((path) => ["--extension", path])] },
|
|
5960
|
+
{ name: "package-root", value: encodeChildPackageInjection({ noExtensions: false, extensionPaths: ["/pkg"] }), expected: ["--extension", "/pkg"] },
|
|
5961
|
+
{ name: "absent", value: undefined, expected: curated.flatMap((path) => ["--extension", path]) },
|
|
5962
|
+
{ name: "malformed", value: JSON.stringify({ version: 1, noExtensions: true, extensionPaths: ["relative/pkg"] }), expected: curated.flatMap((path) => ["--extension", path]) },
|
|
5963
|
+
];
|
|
5964
|
+
for (const scenario of scenarios) {
|
|
5965
|
+
const h = fakePi();
|
|
5966
|
+
const runtime = deps();
|
|
5967
|
+
runtime.deps.childExtensionPaths = curated;
|
|
5968
|
+
runtime.deps.env = { PATH: "/bin", ...(scenario.value === undefined ? {} : { [CHILD_PACKAGE_INJECTION_ENV]: scenario.value }) };
|
|
5969
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
5970
|
+
const { ctx } = fakeContext();
|
|
5971
|
+
await h.fire("session_start", ctx);
|
|
5972
|
+
try {
|
|
5973
|
+
await h.tools.get("subagent_run")!.execute(`injection-${scenario.name}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
5974
|
+
await tick();
|
|
5975
|
+
assert.equal(runtime.spawned.length, 1, scenario.name);
|
|
5976
|
+
const argv = runtime.spawned[0]!;
|
|
5977
|
+
const start = argv.indexOf("--session-dir") + 2;
|
|
5978
|
+
assert.deepEqual(argv.slice(start, start + scenario.expected.length), scenario.expected, scenario.name);
|
|
5979
|
+
assert.equal(argv[start + scenario.expected.length], "--model", `${scenario.name}: no other extension arguments`);
|
|
5980
|
+
assert.equal(argv[argv.indexOf("--tools") + 1], "read,grep,subagent_parent_message", scenario.name);
|
|
4678
5981
|
} finally {
|
|
4679
5982
|
await h.fire("session_shutdown", ctx);
|
|
4680
5983
|
await tick();
|
|
4681
5984
|
}
|
|
4682
5985
|
}
|
|
4683
5986
|
});
|
|
5987
|
+
|
|
5988
|
+
// gentle-shell#1731 T4 (S2, AC6): subagent_run and subagent_continue admit a
|
|
5989
|
+
// writer only while no live writer in the same worktree claims an overlapping
|
|
5990
|
+
// `## Allowed edit surfaces` entry; read-only agents are never registered.
|
|
5991
|
+
test("parallel writers are admitted only with disjoint Allowed edit surfaces end to end", async () => {
|
|
5992
|
+
const h = fakePi();
|
|
5993
|
+
const runtime = deps();
|
|
5994
|
+
const profile = mkdtempSync(join(tmpdir(), "gentle-agents-parallel-writers-"));
|
|
5995
|
+
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
5996
|
+
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
5997
|
+
writeFileSync(join(profile, "agents", "explore.md"), "---\ndescription: maps things\ntools: [read, grep]\n---\nYou map things.");
|
|
5998
|
+
writeFileSync(join(profile, "subagents.json"), JSON.stringify({ max_concurrency: 5, model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
|
|
5999
|
+
const env: NodeJS.ProcessEnv = {};
|
|
6000
|
+
gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile });
|
|
6001
|
+
const { ctx } = fakeContext();
|
|
6002
|
+
await h.fire("session_start", ctx);
|
|
6003
|
+
const scoped = (surface: string) => `Write it.\n\n## Allowed edit surfaces\n${surface}\n\n## Return\nReport`;
|
|
6004
|
+
const run = (agent: string, task: string) => h.tools.get("subagent_run")!.execute("run", { agent, task, mode: "background" }, undefined, undefined, ctx);
|
|
6005
|
+
const taskId = (result: { details: Record<string, unknown> }) => (result.details.gentleAgents as { taskId: string }).taskId;
|
|
6006
|
+
const finish = async (child: FakeChild, id: string) => {
|
|
6007
|
+
child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
6008
|
+
child.emit({ type: "agent_settled" });
|
|
6009
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
6010
|
+
const status = await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
|
|
6011
|
+
if ((status.details.gentleAgents as { status?: string } | undefined)?.status === TASK_STATUS.COMPLETED) return;
|
|
6012
|
+
await tick();
|
|
6013
|
+
}
|
|
6014
|
+
assert.fail(`task ${id} never completed`);
|
|
6015
|
+
};
|
|
6016
|
+
try {
|
|
6017
|
+
const app = taskId(await run("gentle-ai-worker", scoped("src/app.ts")));
|
|
6018
|
+
const other = taskId(await run("gentle-ai-worker", scoped("`src/other.ts`")));
|
|
6019
|
+
await tick();
|
|
6020
|
+
assert.equal(runtime.children.length, 2, "disjoint writers run concurrently");
|
|
6021
|
+
await assert.rejects(run("gentle-ai-worker", scoped("src/*.ts")), (error: Error) => {
|
|
6022
|
+
assert.match(error.message, new RegExp(`task ${app}`));
|
|
6023
|
+
assert.match(error.message, new RegExp(`task ${other}`));
|
|
6024
|
+
assert.match(error.message, /`src\/\*\.ts` overlaps `src\/app\.ts`/);
|
|
6025
|
+
return true;
|
|
6026
|
+
});
|
|
6027
|
+
await run("explore", "Map src/app.ts and src/other.ts");
|
|
6028
|
+
await tick();
|
|
6029
|
+
assert.equal(runtime.children.length, 3, "read-only agents are never blocked by live writers");
|
|
6030
|
+
await finish(runtime.children[0], app);
|
|
6031
|
+
// The continuation inherits src/app.ts and is admitted again only while no
|
|
6032
|
+
// live writer claims an overlapping entry.
|
|
6033
|
+
await assert.rejects(run("gentle-ai-worker", scoped("src/**")), new RegExp(`task ${other}`), "src/** still overlaps the live src/other.ts writer");
|
|
6034
|
+
const blocking = taskId(await run("gentle-ai-worker", scoped("src/app.ts")));
|
|
6035
|
+
await assert.rejects(h.tools.get("subagent_continue")!.execute("follow", { task_id: app, prompt: "Continue.", mode: "background" }, undefined, undefined, ctx), new RegExp(`task ${blocking}`));
|
|
6036
|
+
await finish(runtime.children[3], blocking);
|
|
6037
|
+
await h.tools.get("subagent_continue")!.execute("follow", { task_id: app, prompt: "Continue.", mode: "background" }, undefined, undefined, ctx);
|
|
6038
|
+
await tick();
|
|
6039
|
+
assert.equal(runtime.children.length, 5, "the continuation is admitted once its surfaces are free");
|
|
6040
|
+
} finally {
|
|
6041
|
+
await h.fire("session_shutdown", ctx);
|
|
6042
|
+
rmSync(profile, TEST_DIR_REMOVAL);
|
|
6043
|
+
}
|
|
6044
|
+
});
|
|
6045
|
+
|
|
6046
|
+
// Second verify A1: writers claim surfaces under the canonical worktree root,
|
|
6047
|
+
// so a writer spawned in the session's subdirectory cwd and one sent to the
|
|
6048
|
+
// worktree root through workspace_root are compared.
|
|
6049
|
+
test("a subdirectory session cwd and workspace_root of the same worktree share one writer key", async () => {
|
|
6050
|
+
const h = fakePi();
|
|
6051
|
+
const runtime = deps();
|
|
6052
|
+
const profile = mkdtempSync(join(tmpdir(), "gentle-agents-writer-root-"));
|
|
6053
|
+
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
6054
|
+
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
6055
|
+
writeFileSync(join(profile, "subagents.json"), JSON.stringify({ max_concurrency: 5, model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
|
|
6056
|
+
const sub = join(cwd, "sub");
|
|
6057
|
+
mkdirSync(sub, { recursive: true });
|
|
6058
|
+
// Git semantics: any path inside the project resolves to the project root.
|
|
6059
|
+
const resolveWorktree = (path: string, base: string) => {
|
|
6060
|
+
const target = resolve(base, path);
|
|
6061
|
+
return { root: target === cwd || target.startsWith(`${cwd}${sep}`) ? cwd : target, commonDir: "/fixture/common" };
|
|
6062
|
+
};
|
|
6063
|
+
const env: NodeJS.ProcessEnv = {};
|
|
6064
|
+
gentleAgents(h.pi, env, { ...runtime.deps, resolveWorktree, env, agentHome: profile });
|
|
6065
|
+
const { ctx } = fakeContext();
|
|
6066
|
+
Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [] } });
|
|
6067
|
+
await h.fire("session_start", ctx);
|
|
6068
|
+
const task = "Write it.\n\n## Allowed edit surfaces\nsrc/app.ts\n\n## Return\nReport";
|
|
6069
|
+
try {
|
|
6070
|
+
const first = await h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task, mode: "background" }, undefined, undefined, ctx);
|
|
6071
|
+
const firstId = (first.details.gentleAgents as { taskId: string }).taskId;
|
|
6072
|
+
await assert.rejects(h.tools.get("subagent_run")!.execute("run", { agent: "gentle-ai-worker", task, workspace_root: cwd, mode: "background" }, undefined, undefined, ctx), new RegExp(`task ${firstId}`));
|
|
6073
|
+
} finally {
|
|
6074
|
+
await h.fire("session_shutdown", ctx);
|
|
6075
|
+
rmSync(profile, TEST_DIR_REMOVAL);
|
|
6076
|
+
}
|
|
6077
|
+
});
|
|
6078
|
+
|
|
6079
|
+
// Background jobs share the subagent parent delivery router: the exit notice
|
|
6080
|
+
// is stored and woken like an idle completion, or steered into a running turn.
|
|
6081
|
+
function fakeJobShell() {
|
|
6082
|
+
const runs: Array<{ command: string; onData(data: Buffer): void; signal?: AbortSignal; exit(code: number): void }> = [];
|
|
6083
|
+
const shell = () => ({
|
|
6084
|
+
operations: {
|
|
6085
|
+
exec: (command: string, _cwd: string, options: { onData(data: Buffer): void; signal?: AbortSignal }) => new Promise<{ exitCode: number | null }>((done, fail) => {
|
|
6086
|
+
options.signal?.addEventListener("abort", () => fail(new Error("aborted")), { once: true });
|
|
6087
|
+
runs.push({ command, onData: options.onData, signal: options.signal, exit: (code) => done({ exitCode: code }) });
|
|
6088
|
+
}),
|
|
6089
|
+
},
|
|
6090
|
+
});
|
|
6091
|
+
const dir = mkdtempSync(join(tmpdir(), "gentle-jobs-agents-"));
|
|
6092
|
+
return { runs, shell, dir, cleanup: () => rmSync(dir, TEST_DIR_REMOVAL) };
|
|
6093
|
+
}
|
|
6094
|
+
// A job settles only after its log file closes, which takes real I/O turns.
|
|
6095
|
+
const jobIo = () => new Promise((done) => setTimeout(done, 50));
|
|
6096
|
+
const toolText = (result: { content: Array<{ text?: string }> }) => result.content.map((part) => part.text ?? "").join("\n");
|
|
6097
|
+
|
|
6098
|
+
test("standalone bash sleep is blocked only while background work is active (#1903)", async () => {
|
|
6099
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
6100
|
+
const jobs = fakeJobShell();
|
|
6101
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6102
|
+
const { ctx } = fakeContext();
|
|
6103
|
+
const call = async (toolName: string, command: string) =>
|
|
6104
|
+
(await fire("tool_call", ctx, { toolCallId: "t", toolName, input: { command } })).find((result) => result !== undefined) as { block?: boolean; reason?: string } | undefined;
|
|
6105
|
+
try {
|
|
6106
|
+
await fire("session_start", ctx);
|
|
6107
|
+
// Without background work, sleep is ordinary shell use.
|
|
6108
|
+
assert.equal(await call("bash", "sleep 10"), undefined);
|
|
6109
|
+
|
|
6110
|
+
await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx);
|
|
6111
|
+
for (const command of ["sleep 10", " sleep 2m ", "sleep 0.5;", "sleep 5 && sleep 5"]) {
|
|
6112
|
+
const blocked = await call("bash", command);
|
|
6113
|
+
assert.equal(blocked?.block, true, command);
|
|
6114
|
+
assert.match(blocked!.reason!, /background work is active/);
|
|
6115
|
+
assert.match(blocked!.reason!, /End the turn/);
|
|
6116
|
+
}
|
|
6117
|
+
// Real wait conditions, mixed commands and the sanctioned background tools stay allowed.
|
|
6118
|
+
for (const command of ["until curl -s x; do sleep 1; done", "sleep 5 && gh run view 42", "for i in 1 2; do sleep 1; echo $i; done", "sleep $N"]) {
|
|
6119
|
+
assert.equal(await call("bash", command), undefined, command);
|
|
6120
|
+
}
|
|
6121
|
+
assert.equal(await call("bash_background", "sleep 100"), undefined);
|
|
6122
|
+
assert.equal(await call("monitor", "sleep 100"), undefined);
|
|
6123
|
+
|
|
6124
|
+
jobs.runs[0]!.exit(0);
|
|
6125
|
+
await eventually(() => sent.some((entry) => entry.message.customType === "gentle-jobs.notice"), "the job must settle before sleep is allowed again");
|
|
6126
|
+
assert.equal(await call("bash", "sleep 10"), undefined, "sleep is allowed again once background work settles");
|
|
6127
|
+
} finally {
|
|
6128
|
+
await fire("session_shutdown", ctx);
|
|
6129
|
+
jobs.cleanup();
|
|
6130
|
+
}
|
|
6131
|
+
});
|
|
6132
|
+
|
|
6133
|
+
test("bash_background returns at once, and an idle parent gets one stored exit notice and one wake", async () => {
|
|
6134
|
+
const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
|
|
6135
|
+
const jobs = fakeJobShell();
|
|
6136
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6137
|
+
const { ctx } = fakeContext();
|
|
6138
|
+
try {
|
|
6139
|
+
await fire("session_start", ctx);
|
|
6140
|
+
const started = toolText(await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx));
|
|
6141
|
+
assert.match(started, /job-1/);
|
|
6142
|
+
assert.ok(started.includes(join(jobs.dir, "job-1.log")), "the output file path is returned");
|
|
6143
|
+
assert.match(started, /notified once/i);
|
|
6144
|
+
assert.equal(jobs.runs[0]!.command, "gh run watch 42 --exit-status");
|
|
6145
|
+
assert.equal(sent.length, 0, "nothing reaches the parent while the job runs");
|
|
6146
|
+
jobs.runs[0]!.onData(Buffer.from("check build: fail\n"));
|
|
6147
|
+
jobs.runs[0]!.exit(1);
|
|
6148
|
+
await eventually(
|
|
6149
|
+
() => sent.some((entry) => entry.message.customType === "gentle-jobs.notice") && userMessages.length > 0,
|
|
6150
|
+
"the closed job log must deliver its exit notice and wake before assertions",
|
|
6151
|
+
);
|
|
6152
|
+
const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
|
|
6153
|
+
assert.equal(notices.length, 1, "the exit is reported exactly once");
|
|
6154
|
+
assert.deepEqual(notices[0]!.options, { triggerTurn: false }, "an idle parent stores the notice without a direct turn");
|
|
6155
|
+
const content = String(notices[0]!.message.content);
|
|
6156
|
+
for (const expected of [/job-1/, /"CI"/, /exited with code 1/, /gh run watch 42 --exit-status/, /check build: fail/, /job-1\.log/]) assert.match(content, expected);
|
|
6157
|
+
assert.equal(userMessages.length, 1, "the idle parent is woken exactly once");
|
|
6158
|
+
assert.deepEqual(delivery, ["custom:gentle-jobs.notice", "user"], "the notice is stored before the wake");
|
|
6159
|
+
assert.match(renderers.get("gentle-jobs.notice")!(notices[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /exited with code 1/);
|
|
6160
|
+
await fire("turn_end", ctx);
|
|
6161
|
+
await fire("agent_settled", ctx);
|
|
6162
|
+
assert.equal(sent.length, 1, "later boundaries never replay the notice");
|
|
6163
|
+
assert.equal(userMessages.length, 1, "later boundaries never repeat the wake");
|
|
6164
|
+
} finally {
|
|
6165
|
+
await fire("session_shutdown", ctx);
|
|
6166
|
+
jobs.cleanup();
|
|
6167
|
+
}
|
|
6168
|
+
});
|
|
6169
|
+
|
|
6170
|
+
test("a busy parent gets the exit notice steered into its run at the next turn boundary", async () => {
|
|
6171
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
6172
|
+
const jobs = fakeJobShell();
|
|
6173
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6174
|
+
const { ctx } = fakeContext();
|
|
6175
|
+
try {
|
|
6176
|
+
await fire("session_start", ctx);
|
|
6177
|
+
await fire("agent_start", ctx);
|
|
6178
|
+
await tools.get("bash_background")!.execute("b1", { command: "make build" }, undefined, undefined, ctx);
|
|
6179
|
+
jobs.runs[0]!.exit(0);
|
|
6180
|
+
await jobIo();
|
|
6181
|
+
assert.equal(sent.length, 0, "a busy parent is not interrupted mid-tool-call");
|
|
6182
|
+
await fire("turn_end", ctx);
|
|
6183
|
+
assert.equal(sent.length, 1);
|
|
6184
|
+
assert.deepEqual(sent[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
6185
|
+
assert.match(String(sent[0]!.message.content), /job-1 \("make build"\) exited with code 0/);
|
|
6186
|
+
assert.equal(userMessages.length, 0, "a running parent needs no wake");
|
|
6187
|
+
} finally {
|
|
6188
|
+
await fire("session_shutdown", ctx);
|
|
6189
|
+
jobs.cleanup();
|
|
6190
|
+
}
|
|
6191
|
+
});
|
|
6192
|
+
|
|
6193
|
+
test("job_list and job_stop cover this session's jobs; stopped jobs send no notice and shutdown stops the rest", async () => {
|
|
6194
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
6195
|
+
const jobs = fakeJobShell();
|
|
6196
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6197
|
+
const { ctx } = fakeContext();
|
|
6198
|
+
try {
|
|
6199
|
+
await fire("session_start", ctx);
|
|
6200
|
+
assert.match(toolText(await tools.get("job_list")!.execute("l0", {}, undefined, undefined, ctx)), /No background jobs/);
|
|
6201
|
+
await tools.get("bash_background")!.execute("b1", { command: "sleep 100", label: "long" }, undefined, undefined, ctx);
|
|
6202
|
+
await tools.get("bash_background")!.execute("b2", { command: "npm run dev" }, undefined, undefined, ctx);
|
|
6203
|
+
const listed = toolText(await tools.get("job_list")!.execute("l1", {}, undefined, undefined, ctx));
|
|
6204
|
+
assert.match(listed, /job-1 · running · long/);
|
|
6205
|
+
assert.match(listed, /job-2 · running · npm run dev/);
|
|
6206
|
+
assert.match(toolText(await tools.get("job_stop")!.execute("s1", { job_id: "job-1" }, undefined, undefined, ctx)), /Stopped job-1/);
|
|
6207
|
+
assert.equal(jobs.runs[0]!.signal?.aborted, true, "the job's process tree is killed");
|
|
6208
|
+
await assert.rejects(async () => tools.get("job_stop")!.execute("s2", { job_id: "job-9" }, undefined, undefined, ctx), /No background job job-9 in this session/);
|
|
6209
|
+
await jobIo();
|
|
6210
|
+
assert.equal(sent.length, 0, "a stopped job sends no exit notice");
|
|
6211
|
+
assert.match(toolText(await tools.get("job_list")!.execute("l2", {}, undefined, undefined, ctx)), /job-1 · stopped · long/);
|
|
6212
|
+
await fire("session_shutdown", ctx);
|
|
6213
|
+
assert.equal(jobs.runs[1]!.signal?.aborted, true, "session shutdown stops every running job");
|
|
6214
|
+
await jobIo();
|
|
6215
|
+
assert.equal(sent.length, 0);
|
|
6216
|
+
} finally {
|
|
6217
|
+
jobs.cleanup();
|
|
6218
|
+
}
|
|
6219
|
+
});
|
|
6220
|
+
|
|
6221
|
+
test("/gentle:jobs opens this session's jobs overlay, stops the selected job, and the footer counts running jobs", async () => {
|
|
6222
|
+
const { pi, tools, commands, fire, sent } = fakePi();
|
|
6223
|
+
const jobs = fakeJobShell();
|
|
6224
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6225
|
+
const { ctx, overlays } = fakeContext();
|
|
6226
|
+
const statuses: Array<string | undefined> = [];
|
|
6227
|
+
const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number }> } }> = [];
|
|
6228
|
+
const unsubscribe = pi.events.on("gentle-ai:jobs-sidebar", (value) => { snapshots.push(value as typeof snapshots[number]); });
|
|
6229
|
+
Object.assign(ctx.ui, { setStatus: (key: string, value: string | undefined) => { if (key === "gentle-jobs") statuses.push(value); } });
|
|
6230
|
+
try {
|
|
6231
|
+
await fire("session_start", ctx);
|
|
6232
|
+
await tools.get("bash_background")!.execute("b1", { command: "gh run watch 42 --exit-status", label: "CI" }, undefined, undefined, ctx);
|
|
6233
|
+
assert.equal(statuses.at(-1), "⧗ 1 job");
|
|
6234
|
+
assert.equal(snapshots.at(-1)?.sessionId, ctx.sessionManager.getSessionId());
|
|
6235
|
+
assert.deepEqual(snapshots.at(-1)?.snapshot.jobs.map(({ id, label }) => ({ id, label })), [{ id: "job-1", label: "CI" }]);
|
|
6236
|
+
const opened = commands.get("gentle:jobs")!.handler("", ctx);
|
|
6237
|
+
await tick();
|
|
6238
|
+
const view = overlays.at(-1)!;
|
|
6239
|
+
const screen = view.render(100).map(stripAnsi).join("\n");
|
|
6240
|
+
assert.match(screen, /Jobs · 1 running · 0 finished/);
|
|
6241
|
+
assert.match(screen, /gh run watch 42 --exit-status/);
|
|
6242
|
+
view.handleInput("s");
|
|
6243
|
+
assert.equal(jobs.runs[0]!.signal?.aborted, true, "s stops the selected job");
|
|
6244
|
+
assert.equal(statuses.at(-1), undefined, "the footer count clears when nothing runs");
|
|
6245
|
+
assert.deepEqual(snapshots.at(-1)?.snapshot.jobs, [], "the Status descriptions clear when nothing runs");
|
|
6246
|
+
await jobIo();
|
|
6247
|
+
const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
|
|
6248
|
+
assert.equal(notices.length, 1, "the agent was told it would be notified, so a human stop is reported once");
|
|
6249
|
+
assert.match(String(notices[0]!.message.content), /job-1 \("CI"\) was stopped by the user/);
|
|
6250
|
+
view.handleInput("q");
|
|
6251
|
+
await opened;
|
|
6252
|
+
} finally {
|
|
6253
|
+
unsubscribe();
|
|
6254
|
+
await fire("session_shutdown", ctx);
|
|
6255
|
+
jobs.cleanup();
|
|
6256
|
+
}
|
|
6257
|
+
});
|
|
6258
|
+
|
|
6259
|
+
test("a job exit notice whose forwarding throws while the parent is idle is retried after the bounded grace", async () => {
|
|
6260
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
6261
|
+
const harness = deps();
|
|
6262
|
+
const timers = recordTimers(harness.deps);
|
|
6263
|
+
const jobs = fakeJobShell();
|
|
6264
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6265
|
+
const { ctx } = fakeContext();
|
|
6266
|
+
try {
|
|
6267
|
+
await fire("session_start", ctx);
|
|
6268
|
+
await tools.get("bash_background")!.execute("b1", { command: "make ci" }, undefined, undefined, ctx);
|
|
6269
|
+
const sendMessage = pi.sendMessage;
|
|
6270
|
+
Object.assign(pi, { sendMessage: () => { throw new Error("host busy"); } });
|
|
6271
|
+
jobs.runs[0]!.exit(0);
|
|
6272
|
+
await jobIo();
|
|
6273
|
+
assert.equal(sent.length, 0);
|
|
6274
|
+
assert.equal(userMessages.length, 0);
|
|
6275
|
+
Object.assign(pi, { sendMessage });
|
|
6276
|
+
// No turn boundary is coming for an idle parent, so the retry is a timer.
|
|
6277
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1, "one bounded retry flush is armed");
|
|
6278
|
+
await tick();
|
|
6279
|
+
const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
|
|
6280
|
+
assert.equal(notices.length, 1, "the requeued notice reaches the idle parent");
|
|
6281
|
+
assert.match(String(notices[0]!.message.content), /job-1 \("make ci"\) exited with code 0/);
|
|
6282
|
+
assert.equal(userMessages.length, 1, "and the idle parent is woken for it");
|
|
6283
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
|
|
6284
|
+
await tick();
|
|
6285
|
+
assert.equal(sent.length, 1, "a successful retry never delivers the notice twice");
|
|
6286
|
+
} finally {
|
|
6287
|
+
await fire("session_shutdown", ctx);
|
|
6288
|
+
jobs.cleanup();
|
|
6289
|
+
}
|
|
6290
|
+
});
|
|
6291
|
+
|
|
6292
|
+
for (const boundary of ["session_compact", "session_compact_failed"] as const) {
|
|
6293
|
+
test(`a parent compacting outside an agent run holds a job exit notice until ${boundary}, then wakes it`, async () => {
|
|
6294
|
+
const { pi, tools, fire, sent, userMessages, delivery, setIdle } = fakePi();
|
|
6295
|
+
const harness = deps();
|
|
6296
|
+
const timers = recordTimers(harness.deps);
|
|
6297
|
+
const jobs = fakeJobShell();
|
|
6298
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6299
|
+
const { ctx } = fakeContext();
|
|
6300
|
+
try {
|
|
6301
|
+
await fire("session_start", ctx);
|
|
6302
|
+
await tools.get("bash_background")!.execute("b1", { command: "make ci" }, undefined, undefined, ctx);
|
|
6303
|
+
setIdle(false);
|
|
6304
|
+
jobs.runs[0]!.exit(1);
|
|
6305
|
+
await jobIo();
|
|
6306
|
+
assert.equal(sent.length, 0, "no direct custom-message turn starts while the parent compacts");
|
|
6307
|
+
assert.equal(userMessages.length, 0, "no wake is sent while the parent compacts");
|
|
6308
|
+
await fire(boundary, ctx);
|
|
6309
|
+
assert.equal(sent.length, 0, "the boundary handler runs before Pi leaves the compacting state");
|
|
6310
|
+
setIdle(true);
|
|
6311
|
+
assert.equal(timers.run(0), 1, "one deferred flush is scheduled for the compaction boundary");
|
|
6312
|
+
await tick();
|
|
6313
|
+
assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }], "the held notice is stored without a direct turn");
|
|
6314
|
+
assert.deepEqual(delivery, ["custom:gentle-jobs.notice", "user"], "one wake follows the held notice");
|
|
6315
|
+
} finally {
|
|
6316
|
+
await fire("session_shutdown", ctx);
|
|
6317
|
+
jobs.cleanup();
|
|
6318
|
+
}
|
|
6319
|
+
});
|
|
6320
|
+
}
|
|
6321
|
+
|
|
6322
|
+
test("session shutdown removes the default background job log directory", async () => {
|
|
6323
|
+
const { pi, tools, fire } = fakePi();
|
|
6324
|
+
const jobs = fakeJobShell();
|
|
6325
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell });
|
|
6326
|
+
const { ctx } = fakeContext();
|
|
6327
|
+
try {
|
|
6328
|
+
await fire("session_start", ctx);
|
|
6329
|
+
const started = toolText(await tools.get("bash_background")!.execute("b1", { command: "sleep 100" }, undefined, undefined, ctx));
|
|
6330
|
+
const outputPath = /Output: (.+)$/m.exec(started)![1]!;
|
|
6331
|
+
assert.ok(existsSync(dirname(outputPath)), "the job log directory exists while the session runs");
|
|
6332
|
+
await fire("session_shutdown", ctx);
|
|
6333
|
+
assert.equal(existsSync(dirname(outputPath)), false, "the job log directory is removed with the session");
|
|
6334
|
+
} finally {
|
|
6335
|
+
jobs.cleanup();
|
|
6336
|
+
}
|
|
6337
|
+
});
|
|
6338
|
+
|
|
6339
|
+
test("session shutdown never hangs on a job whose process ignores the stop", { timeout: 5000 }, async () => {
|
|
6340
|
+
const { pi, tools, fire } = fakePi();
|
|
6341
|
+
const dir = mkdtempSync(join(tmpdir(), "gentle-jobs-stuck-"));
|
|
6342
|
+
// A process that never exits, even after its abort signal.
|
|
6343
|
+
const stuck = () => ({ operations: { exec: () => new Promise<{ exitCode: number | null }>(() => {}) } });
|
|
6344
|
+
const schedule: AgentsDeps["schedule"] = (fn, ms) => { const timer = setTimeout(fn, Math.min(ms, 10)); timer.unref(); return () => clearTimeout(timer); };
|
|
6345
|
+
gentleAgents(pi, {}, { ...deps().deps, schedule, jobShell: stuck });
|
|
6346
|
+
const { ctx } = fakeContext();
|
|
6347
|
+
try {
|
|
6348
|
+
await fire("session_start", ctx);
|
|
6349
|
+
await tools.get("bash_background")!.execute("b1", { command: "sleep 100" }, undefined, undefined, ctx);
|
|
6350
|
+
await fire("session_shutdown", ctx);
|
|
6351
|
+
} finally {
|
|
6352
|
+
rmSync(dir, TEST_DIR_REMOVAL);
|
|
6353
|
+
}
|
|
6354
|
+
});
|
|
6355
|
+
|
|
6356
|
+
// A monitor reports output lines while it runs, through the same router.
|
|
6357
|
+
test("monitor delivers line events to an idle parent with one wake, and reports its end with the event count", async () => {
|
|
6358
|
+
const { pi, tools, fire, sent, userMessages, renderers } = fakePi();
|
|
6359
|
+
const harness = deps();
|
|
6360
|
+
const timers = recordTimers(harness.deps);
|
|
6361
|
+
const jobs = fakeJobShell();
|
|
6362
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6363
|
+
const { ctx } = fakeContext();
|
|
6364
|
+
try {
|
|
6365
|
+
await fire("session_start", ctx);
|
|
6366
|
+
const started = toolText(await tools.get("monitor")!.execute("m1", { command: "gh run watch 7", label: "CI checks", timeout_seconds: 900 }, undefined, undefined, ctx));
|
|
6367
|
+
assert.match(started, /Started monitor job-1 \("CI checks"\)/);
|
|
6368
|
+
assert.match(started, /900s/);
|
|
6369
|
+
jobs.runs[0]!.onData(Buffer.from("lint: pass\nunit: fail\n"));
|
|
6370
|
+
assert.equal(sent.length, 0, "lines wait for the batch window");
|
|
6371
|
+
assert.equal(timers.run(200), 1);
|
|
6372
|
+
await tick();
|
|
6373
|
+
const events = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
|
|
6374
|
+
assert.equal(events.length, 1);
|
|
6375
|
+
assert.deepEqual(events[0]!.options, { triggerTurn: false });
|
|
6376
|
+
assert.match(String(events[0]!.message.content), /Monitor job-1 \("CI checks"\) reported 2 new lines:\nlint: pass\nunit: fail/);
|
|
6377
|
+
assert.equal(userMessages.length, 1, "the idle parent is woken for the events");
|
|
6378
|
+
assert.match(renderers.get("gentle-jobs.notice")!(events[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /unit: fail/);
|
|
6379
|
+
jobs.runs[0]!.onData(Buffer.from("e2e: pass\n"));
|
|
6380
|
+
jobs.runs[0]!.exit(1);
|
|
6381
|
+
await jobIo();
|
|
6382
|
+
const contents = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice").map((entry) => String(entry.message.content));
|
|
6383
|
+
assert.equal(contents.length, 3, "pending lines are delivered before the end notice");
|
|
6384
|
+
assert.match(contents[1]!, /reported 1 new line:\ne2e: pass/);
|
|
6385
|
+
assert.match(contents[2]!, /Monitor job-1 \("CI checks"\) exited with code 1 after .* 3 events\./);
|
|
6386
|
+
} finally {
|
|
6387
|
+
await fire("session_shutdown", ctx);
|
|
6388
|
+
jobs.cleanup();
|
|
6389
|
+
}
|
|
6390
|
+
});
|
|
6391
|
+
|
|
6392
|
+
test("monitor events wait for a busy parent's turn boundary and coalesce into one notice", async () => {
|
|
6393
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
6394
|
+
const harness = deps();
|
|
6395
|
+
const timers = recordTimers(harness.deps);
|
|
6396
|
+
const jobs = fakeJobShell();
|
|
6397
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6398
|
+
const { ctx } = fakeContext();
|
|
6399
|
+
try {
|
|
6400
|
+
await fire("session_start", ctx);
|
|
6401
|
+
await fire("agent_start", ctx);
|
|
6402
|
+
await tools.get("monitor")!.execute("m1", { command: "tail -f log", timeout_seconds: 60 }, undefined, undefined, ctx);
|
|
6403
|
+
jobs.runs[0]!.onData(Buffer.from("ERROR a\n"));
|
|
6404
|
+
timers.run(200);
|
|
6405
|
+
jobs.runs[0]!.onData(Buffer.from("ERROR b\n"));
|
|
6406
|
+
timers.run(200);
|
|
6407
|
+
assert.equal(sent.length, 0, "a busy parent is not interrupted mid-tool-call");
|
|
6408
|
+
await fire("turn_end", ctx);
|
|
6409
|
+
assert.equal(sent.length, 1, "both batches arrive as one notice");
|
|
6410
|
+
assert.deepEqual(sent[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
6411
|
+
assert.match(String(sent[0]!.message.content), /reported 2 new lines:\nERROR a\nERROR b/);
|
|
6412
|
+
// job_stop ends the monitor without any further notice.
|
|
6413
|
+
assert.match(toolText(await tools.get("job_stop")!.execute("s1", { job_id: "job-1" }, undefined, undefined, ctx)), /Stopped job-1/);
|
|
6414
|
+
jobs.runs[0]!.onData(Buffer.from("ERROR c\n"));
|
|
6415
|
+
timers.run(200);
|
|
6416
|
+
timers.run(60_000);
|
|
6417
|
+
await jobIo();
|
|
6418
|
+
await fire("turn_end", ctx);
|
|
6419
|
+
assert.equal(sent.length, 1, "a stopped monitor reports nothing more");
|
|
6420
|
+
} finally {
|
|
6421
|
+
await fire("session_shutdown", ctx);
|
|
6422
|
+
jobs.cleanup();
|
|
6423
|
+
}
|
|
6424
|
+
});
|
|
6425
|
+
|
|
6426
|
+
test("monitor rejects a missing or out-of-range timeout before starting anything", async () => {
|
|
6427
|
+
const { pi, tools, fire } = fakePi();
|
|
6428
|
+
const jobs = fakeJobShell();
|
|
6429
|
+
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6430
|
+
const { ctx } = fakeContext();
|
|
6431
|
+
try {
|
|
6432
|
+
await fire("session_start", ctx);
|
|
6433
|
+
for (const params of [{ command: "x" }, { command: "x", timeout_seconds: 0 }, { command: "x", timeout_seconds: 3600 }]) {
|
|
6434
|
+
await assert.rejects(async () => tools.get("monitor")!.execute("m", params, undefined, undefined, ctx), /timeout_seconds must be a whole number from 1 to 1800/);
|
|
6435
|
+
}
|
|
6436
|
+
assert.equal(jobs.runs.length, 0);
|
|
6437
|
+
} finally {
|
|
6438
|
+
await fire("session_shutdown", ctx);
|
|
6439
|
+
jobs.cleanup();
|
|
6440
|
+
}
|
|
6441
|
+
});
|
|
6442
|
+
|
|
6443
|
+
test("events from two interleaved monitors coalesce per monitor while the parent is busy", async () => {
|
|
6444
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
6445
|
+
const harness = deps();
|
|
6446
|
+
const timers = recordTimers(harness.deps);
|
|
6447
|
+
const jobs = fakeJobShell();
|
|
6448
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6449
|
+
const { ctx } = fakeContext();
|
|
6450
|
+
try {
|
|
6451
|
+
await fire("session_start", ctx);
|
|
6452
|
+
await fire("agent_start", ctx);
|
|
6453
|
+
await tools.get("monitor")!.execute("m1", { command: "watch a", label: "A", timeout_seconds: 60 }, undefined, undefined, ctx);
|
|
6454
|
+
await tools.get("monitor")!.execute("m2", { command: "watch b", label: "B", timeout_seconds: 60 }, undefined, undefined, ctx);
|
|
6455
|
+
for (const round of [1, 2, 3]) {
|
|
6456
|
+
jobs.runs[0]!.onData(Buffer.from(`a${round}\n`));
|
|
6457
|
+
jobs.runs[1]!.onData(Buffer.from(`b${round}\n`));
|
|
6458
|
+
timers.run(200);
|
|
6459
|
+
}
|
|
6460
|
+
await fire("turn_end", ctx);
|
|
6461
|
+
const contents = sent.map((entry) => String(entry.message.content));
|
|
6462
|
+
assert.equal(contents.length, 2, "one notice per monitor, not one per batch");
|
|
6463
|
+
assert.match(contents[0]!, /\("A"\) reported 3 new lines:\na1\na2\na3/);
|
|
6464
|
+
assert.match(contents[1]!, /\("B"\) reported 3 new lines:\nb1\nb2\nb3/);
|
|
6465
|
+
} finally {
|
|
6466
|
+
await fire("session_shutdown", ctx);
|
|
6467
|
+
jobs.cleanup();
|
|
6468
|
+
}
|
|
6469
|
+
});
|
|
6470
|
+
|
|
6471
|
+
test("a human stop of a monitor from /gentle:jobs delivers its pending lines, then one stop notice, then nothing", async () => {
|
|
6472
|
+
const { pi, tools, commands, fire, sent } = fakePi();
|
|
6473
|
+
const harness = deps();
|
|
6474
|
+
const timers = recordTimers(harness.deps);
|
|
6475
|
+
const jobs = fakeJobShell();
|
|
6476
|
+
gentleAgents(pi, {}, { ...harness.deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6477
|
+
const { ctx, overlays } = fakeContext();
|
|
6478
|
+
try {
|
|
6479
|
+
await fire("session_start", ctx);
|
|
6480
|
+
await fire("agent_start", ctx);
|
|
6481
|
+
await tools.get("monitor")!.execute("m1", { command: "tail -f log", label: "log", timeout_seconds: 60 }, undefined, undefined, ctx);
|
|
6482
|
+
jobs.runs[0]!.onData(Buffer.from("ERROR seen\n"));
|
|
6483
|
+
const opened = commands.get("gentle:jobs")!.handler("", ctx);
|
|
6484
|
+
await tick();
|
|
6485
|
+
overlays.at(-1)!.handleInput("s");
|
|
6486
|
+
overlays.at(-1)!.handleInput("q");
|
|
6487
|
+
await opened;
|
|
6488
|
+
assert.equal(jobs.runs[0]!.signal?.aborted, true);
|
|
6489
|
+
jobs.runs[0]!.onData(Buffer.from("ERROR after stop\n"));
|
|
6490
|
+
timers.run(200);
|
|
6491
|
+
timers.run(60_000);
|
|
6492
|
+
await jobIo();
|
|
6493
|
+
await fire("turn_end", ctx);
|
|
6494
|
+
const contents = sent.map((entry) => String(entry.message.content));
|
|
6495
|
+
assert.equal(contents.length, 2, `notices: ${JSON.stringify(contents)}`);
|
|
6496
|
+
assert.match(contents[0]!, /reported 1 new line:\nERROR seen/);
|
|
6497
|
+
assert.match(contents[1]!, /Monitor job-1 \("log"\) was stopped by the user after .*, 1 event\./);
|
|
6498
|
+
} finally {
|
|
6499
|
+
await fire("session_shutdown", ctx);
|
|
6500
|
+
jobs.cleanup();
|
|
6501
|
+
}
|
|
6502
|
+
});
|