gentle-pi 4.2.0 → 4.4.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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
@@ -13,11 +13,11 @@ 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
15
|
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
|
|
16
|
-
import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
16
|
+
import { visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
17
17
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
18
18
|
import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection } from "../lib/child-package-injection.ts";
|
|
19
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";
|
|
20
|
-
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
|
|
20
|
+
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceError, SessionPresenceRegistry, type PresenceRecord } from "../lib/agents-session-transport.ts";
|
|
21
21
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
|
|
22
22
|
import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
|
|
23
23
|
import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
|
|
@@ -25,11 +25,13 @@ import { applyTaskEvent, emptyThread, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TAS
|
|
|
25
25
|
import { NativePointerScope } from "../lib/native-pointer-region.ts";
|
|
26
26
|
import { PresenceCursor, PresencePublisher, listPresence, readActivity, readDiscovery } from "../lib/orchestrator-presence.ts";
|
|
27
27
|
import { OrchestratorScopeCache } from "../lib/orchestrator-scope.ts";
|
|
28
|
+
import { TODO_PROGRESS_EVENT } from "../lib/shell-todo.ts";
|
|
28
29
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
29
30
|
import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
30
|
-
import { AgentRunner, REQUESTED_TOOLS_ENV } from "../lib/agents-runner.ts";
|
|
31
|
+
import { AgentRunner, REQUESTED_TOOLS_ENV, type TaskRequest } from "../lib/agents-runner.ts";
|
|
31
32
|
import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
|
|
32
33
|
import { bindSessionProfile, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
34
|
+
import { taskHeader } from "../lib/agents-view.ts";
|
|
33
35
|
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
34
36
|
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
35
37
|
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
@@ -241,7 +243,8 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
241
243
|
const factory = widgets.get("gentle-agents");
|
|
242
244
|
return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
|
|
243
245
|
};
|
|
244
|
-
|
|
246
|
+
const widgetComponent = () => widgets.get("gentle-agents")?.(tui, plainTheme) as Component | undefined;
|
|
247
|
+
return { ctx, widget, widgetComponent, dialogs, overlays, customCompletions, customOptions };
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
for (const scenario of ["background", "task", "append-failure", "overflow", "bytes", "replacement", "task-replacement", "unsafe-id", "no-transport"] as const) {
|
|
@@ -578,7 +581,9 @@ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher
|
|
|
578
581
|
const runtime = deps();
|
|
579
582
|
const scheduler = fakeScheduler();
|
|
580
583
|
runtime.deps.schedule = scheduler.schedule;
|
|
581
|
-
|
|
584
|
+
// The launch reads the session profile through this env, so it must keep the
|
|
585
|
+
// scratch config home instead of reaching the runner's real one.
|
|
586
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1", GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME };
|
|
582
587
|
gentleAgents(h.pi, {}, runtime.deps);
|
|
583
588
|
const { ctx } = fakeContext();
|
|
584
589
|
Object.assign(ctx, { mode: "rpc", hasUI: true });
|
|
@@ -1413,6 +1418,152 @@ test("live-only extension instances discover same-profile peers across cwd bound
|
|
|
1413
1418
|
await Promise.all([panel.opened, peerPanel.opened]);
|
|
1414
1419
|
});
|
|
1415
1420
|
|
|
1421
|
+
test("rapid child deltas coalesce presence publication to a leading publish plus one trailing publish", async (t) => {
|
|
1422
|
+
const profile = liveProfile("live-coalesce-profile");
|
|
1423
|
+
const h = fakePi();
|
|
1424
|
+
const runtime = deps();
|
|
1425
|
+
const timers = recordTimers(runtime.deps);
|
|
1426
|
+
const { ctx } = fakeContext();
|
|
1427
|
+
Object.assign(ctx.sessionManager, {
|
|
1428
|
+
getSessionId: () => "coalesce-live",
|
|
1429
|
+
getSessionName: () => "coalesce-live",
|
|
1430
|
+
getCwd: () => join(realpathSync(root), "coalesce-live"),
|
|
1431
|
+
});
|
|
1432
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
|
|
1433
|
+
const update = t.mock.method(PresencePublisher.prototype, "update");
|
|
1434
|
+
await h.fire("session_start", ctx);
|
|
1435
|
+
timers.run(400); // expire session_start's own publication window
|
|
1436
|
+
update.mock.resetCalls(); // session_start's own publication is out of scope
|
|
1437
|
+
|
|
1438
|
+
const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
|
|
1439
|
+
await tick();
|
|
1440
|
+
const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
|
|
1441
|
+
assert.ok(taskId);
|
|
1442
|
+
assert.equal(runtime.children.length, 1);
|
|
1443
|
+
// The admission burst publishes its discrete semantic events immediately:
|
|
1444
|
+
// the admission itself plus the child's worktree registration. Per-delta
|
|
1445
|
+
// events below still collapse into one trailing publish.
|
|
1446
|
+
assert.equal(update.mock.callCount(), 2, "the admission burst publishes its semantic events on the leading edge");
|
|
1447
|
+
update.mock.resetCalls();
|
|
1448
|
+
|
|
1449
|
+
for (let delta = 0; delta < 20; delta++) {
|
|
1450
|
+
runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: `delta ${delta}` } });
|
|
1451
|
+
}
|
|
1452
|
+
assert.equal(update.mock.callCount(), 0, "per-delta events must not publish while the coalescing window is open");
|
|
1453
|
+
|
|
1454
|
+
timers.run(400);
|
|
1455
|
+
assert.equal(update.mock.callCount(), 1, "exactly one trailing publish after the burst");
|
|
1456
|
+
update.mock.resetCalls();
|
|
1457
|
+
|
|
1458
|
+
// After the window closes, a new burst publishes on the leading edge again.
|
|
1459
|
+
runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "after window" } });
|
|
1460
|
+
assert.equal(update.mock.callCount(), 1, "a call after the window publishes immediately again");
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test("the trailing publication reuses cached scope facts: a served metadata query sees no Git probes", async (t) => {
|
|
1464
|
+
// gentle-shell#2522 regression: the coalesced trailing publish ran while a
|
|
1465
|
+
// peer metadata query (orchestrator_consult/orchestrator_list) was being
|
|
1466
|
+
// served, and its scope projection cache-missed into a synchronous Git
|
|
1467
|
+
// resolve. A metadata query adds no caller Git probes, so the trailing
|
|
1468
|
+
// publication reuses the snapshot the last event-time publish projected —
|
|
1469
|
+
// never probing, never dropping — until an event-time publish refreshes.
|
|
1470
|
+
const profile = liveProfile("live-coalesce-scope-profile");
|
|
1471
|
+
const h = fakePi();
|
|
1472
|
+
const runtime = deps();
|
|
1473
|
+
const timers = recordTimers(runtime.deps);
|
|
1474
|
+
let releaseActivations: () => void = () => {};
|
|
1475
|
+
// A recorded listener makes publishActivityNow project the discovery scope;
|
|
1476
|
+
// the registry holds the consult's activation read until the test releases it.
|
|
1477
|
+
runtime.deps.sessionTransport = {
|
|
1478
|
+
...inertSessionTransport,
|
|
1479
|
+
createRegistry: async () => ({ list: async () => [], listActivations: async () => await new Promise<readonly PresenceRecord[]>(resolve => { releaseActivations = () => resolve([]); }) }),
|
|
1480
|
+
createListener: registry => ({ registry, record: { version: 1, sessionId: "coalesce-scope", endpoint: "/fixture/coalesce-scope", createdAt: 1 }, start: async () => {}, close: async () => {} }),
|
|
1481
|
+
};
|
|
1482
|
+
const probes: string[] = [];
|
|
1483
|
+
// Count only scope-projection probes: admission and launch also resolve
|
|
1484
|
+
// worktrees outside the projection, and only the projection can leak into a
|
|
1485
|
+
// peer metadata query.
|
|
1486
|
+
const realProject = OrchestratorScopeCache.prototype.project;
|
|
1487
|
+
let projecting = false;
|
|
1488
|
+
OrchestratorScopeCache.prototype.project = function (...args: Parameters<typeof realProject>) {
|
|
1489
|
+
projecting = true;
|
|
1490
|
+
try { return realProject.apply(this, args); } finally { projecting = false; }
|
|
1491
|
+
};
|
|
1492
|
+
t.after(() => { OrchestratorScopeCache.prototype.project = realProject; });
|
|
1493
|
+
runtime.deps.resolveWorktree = (path, base) => {
|
|
1494
|
+
if (projecting) probes.push(resolve(base, path));
|
|
1495
|
+
return { root: resolve(base, path), commonDir: "/fixture/common" };
|
|
1496
|
+
};
|
|
1497
|
+
const { ctx } = fakeContext();
|
|
1498
|
+
Object.assign(ctx.sessionManager, {
|
|
1499
|
+
getSessionId: () => "coalesce-scope",
|
|
1500
|
+
getSessionName: () => "coalesce-scope",
|
|
1501
|
+
getCwd: () => join(realpathSync(root), "coalesce-scope"),
|
|
1502
|
+
});
|
|
1503
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
|
|
1504
|
+
const update = t.mock.method(PresencePublisher.prototype, "update");
|
|
1505
|
+
await h.fire("session_start", ctx);
|
|
1506
|
+
assert.ok(probes.length > 0, "the semantic startup publish resolves its facts eagerly");
|
|
1507
|
+
// Wait for the asynchronous session transport to bind its record without
|
|
1508
|
+
// publishing; the metadata tool answers "not ready" until it has.
|
|
1509
|
+
for (let i = 0; i < 100; i++) {
|
|
1510
|
+
const result = await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx);
|
|
1511
|
+
if ((result.details?.gentleAgents as { senderSessionId?: string } | undefined)?.senderSessionId) break;
|
|
1512
|
+
assert.ok(i < 99, "session transport binds within bounded ticks");
|
|
1513
|
+
await tick();
|
|
1514
|
+
}
|
|
1515
|
+
timers.run(400); // expire startup's own publication window
|
|
1516
|
+
update.mock.resetCalls();
|
|
1517
|
+
probes.length = 0;
|
|
1518
|
+
|
|
1519
|
+
const launch = await h.tools.get("subagent_run")!.execute("coalesce", { agent: "local", task: "Stream", mode: "background" }, undefined, undefined, ctx);
|
|
1520
|
+
await tick();
|
|
1521
|
+
const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
|
|
1522
|
+
assert.ok(taskId);
|
|
1523
|
+
assert.ok(probes.length > 0, "the semantic admission publish resolves new membership facts eagerly");
|
|
1524
|
+
// Let a settle publish absorb any late membership change before the measured window.
|
|
1525
|
+
timers.run(400);
|
|
1526
|
+
runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "settle" } });
|
|
1527
|
+
await tick();
|
|
1528
|
+
timers.run(400);
|
|
1529
|
+
update.mock.resetCalls();
|
|
1530
|
+
probes.length = 0;
|
|
1531
|
+
|
|
1532
|
+
runtime.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "delta" } });
|
|
1533
|
+
assert.equal(update.mock.callCount(), 1, "the delta publishes on the leading edge");
|
|
1534
|
+
assert.equal(probes.length, 0, "an unchanged membership never re-resolves facts");
|
|
1535
|
+
|
|
1536
|
+
// Membership changes mid-window (the child settles, leaving the coalesced
|
|
1537
|
+
// path to queue the publish) while a peer metadata query starts being
|
|
1538
|
+
// served — the exact collision of the regression.
|
|
1539
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
|
|
1540
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
1541
|
+
for (let i = 0; i < 100; i++) {
|
|
1542
|
+
const status = await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx);
|
|
1543
|
+
if (/completed/.test(status.content[0]?.text ?? "")) break;
|
|
1544
|
+
assert.ok(i < 99, "child completes within bounded ticks");
|
|
1545
|
+
await tick();
|
|
1546
|
+
}
|
|
1547
|
+
assert.equal(update.mock.callCount(), 1, "the membership change stays behind the trailing edge");
|
|
1548
|
+
assert.equal(probes.length, 0, "queued events must not publish");
|
|
1549
|
+
const pendingConsult = h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer123" }, undefined, undefined, ctx);
|
|
1550
|
+
await tick(); // the consult suspends on its activation read, holding the window open
|
|
1551
|
+
|
|
1552
|
+
timers.run(400); // the trailing publish comes due while the query is being served
|
|
1553
|
+
assert.equal(update.mock.callCount(), 2, "the trailing publication still runs, never dropped");
|
|
1554
|
+
assert.equal(probes.length, 0, "a served metadata query adds no caller Git probes");
|
|
1555
|
+
|
|
1556
|
+
releaseActivations();
|
|
1557
|
+
await pendingConsult;
|
|
1558
|
+
assert.equal(probes.length, 0, "the whole served query stayed probe-free");
|
|
1559
|
+
|
|
1560
|
+
// After the window, an event-time publish refreshes the facts the trailing
|
|
1561
|
+
// reuse deferred: the changed membership resolves eagerly again.
|
|
1562
|
+
h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "coalesce-scope" });
|
|
1563
|
+
assert.ok(probes.length > 0, "an event-time publish refreshes the deferred facts");
|
|
1564
|
+
assert.equal(update.mock.callCount(), 3, "the refresh publishes immediately");
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1416
1567
|
test("manual agents command warns once in RPC mode and stays quiet without UI", async (t) => {
|
|
1417
1568
|
const local = liveInstance(t, liveProfile("live-non-tui"), "non-tui");
|
|
1418
1569
|
Object.assign(local.ctx, { mode: "rpc" });
|
|
@@ -2266,7 +2417,10 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2266
2417
|
assert.equal(captured[index]?.command, "/fixture/pi");
|
|
2267
2418
|
assert.deepEqual(captured[index]?.args, args);
|
|
2268
2419
|
assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
|
|
2269
|
-
|
|
2420
|
+
const scratch = captured[index]?.options.env.TMPDIR;
|
|
2421
|
+
assert.match(scratch ?? "", /[\\/]ga[A-Za-z0-9]{6}$/, "each child receives its own TMPDIR");
|
|
2422
|
+
const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
2423
|
+
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}), [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
|
|
2270
2424
|
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
2271
2425
|
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
2272
2426
|
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
@@ -2713,11 +2867,16 @@ test("foreign clone rejects aliases, absent UI, decline and changed session befo
|
|
|
2713
2867
|
const declined = run.execute("decline", args(foreign), undefined, undefined, ctx);
|
|
2714
2868
|
await tick(); decision(false);
|
|
2715
2869
|
await assert.rejects(declined, /interactive/);
|
|
2870
|
+
// gentle-shell#2314: Git prints Windows roots with forward slashes; that spelling
|
|
2871
|
+
// must pass the canonical-root guard and reach consent like the native one.
|
|
2872
|
+
const forwardSlashes = run.execute("forward-slashes", args(foreign.replaceAll("\\", "/")), undefined, undefined, ctx);
|
|
2873
|
+
await tick(); decision(false);
|
|
2874
|
+
await assert.rejects(forwardSlashes, /interactive/);
|
|
2716
2875
|
const drift = run.execute("drift", args(foreign), undefined, undefined, ctx);
|
|
2717
2876
|
await tick(); id = "replacement"; decision(true);
|
|
2718
2877
|
await assert.rejects(drift, /identity changed/);
|
|
2719
2878
|
assert.equal(runtime.spawned.length, 0);
|
|
2720
|
-
assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length,
|
|
2879
|
+
assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 3);
|
|
2721
2880
|
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2722
2881
|
await h.fire("session_shutdown", ctx);
|
|
2723
2882
|
} finally { rmSync(fixture, TEST_DIR_REMOVAL); }
|
|
@@ -3378,6 +3537,167 @@ test("follow mode re-resolves the defaults on every launch without a notice; Ent
|
|
|
3378
3537
|
assert.deepEqual(session.driftNotices(), []);
|
|
3379
3538
|
});
|
|
3380
3539
|
|
|
3540
|
+
// gentle-shell#2180: the launch seam records which profile layer supplied the
|
|
3541
|
+
// model, next to the model itself, so every task card shows the winning layer
|
|
3542
|
+
// and stale disk edits below a frozen session become visible.
|
|
3543
|
+
interface LaunchCapture {
|
|
3544
|
+
request: TaskRequest;
|
|
3545
|
+
record: TaskRecord;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
function captureLaunches(t: TestContext): LaunchCapture[] {
|
|
3549
|
+
const captures: LaunchCapture[] = [];
|
|
3550
|
+
const original = AgentRunner.prototype.run;
|
|
3551
|
+
t.mock.method(AgentRunner.prototype, "run", function (this: AgentRunner, request: TaskRequest) {
|
|
3552
|
+
const record = original.call(this, request);
|
|
3553
|
+
captures.push({ request, record });
|
|
3554
|
+
return record;
|
|
3555
|
+
});
|
|
3556
|
+
return captures;
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
// A non-Git variant of launchSession: the resolver returns nothing, so the
|
|
3560
|
+
// declaration is the only pin layer (as non-Git writer admission reads it).
|
|
3561
|
+
async function launchNonGitSession(base: ReturnType<typeof pinFixture>, env: Record<string, string> = {}) {
|
|
3562
|
+
const harness = deps();
|
|
3563
|
+
harness.deps.resolveWorktree = () => undefined;
|
|
3564
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome, ...env };
|
|
3565
|
+
const { pi, tools, fire } = fakePi();
|
|
3566
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3567
|
+
const { ctx } = fakeContext();
|
|
3568
|
+
// Outside Git the declaration is read at the session's own cwd, so the
|
|
3569
|
+
// fixture binds a dedicated session directory instead of leaking the
|
|
3570
|
+
// declaration into the shared fixture cwd other tests launch from.
|
|
3571
|
+
const sessionCwd = join(base.root, "session");
|
|
3572
|
+
mkdirSync(join(sessionCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
3573
|
+
copyFileSync(base.declarationPath, join(sessionCwd, ".pi", "gentle-ai", "profile.json"));
|
|
3574
|
+
ctx.sessionManager.getCwd = () => sessionCwd;
|
|
3575
|
+
await fire("session_start", ctx);
|
|
3576
|
+
return {
|
|
3577
|
+
launch: async () => {
|
|
3578
|
+
await tools.get("subagent_run")!.execute("source", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
3579
|
+
await tick();
|
|
3580
|
+
},
|
|
3581
|
+
shutdown: async () => {
|
|
3582
|
+
await fire("session_shutdown", ctx);
|
|
3583
|
+
await tick();
|
|
3584
|
+
},
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
test("a bound session launch records the binding as the model source on the task record", async t => {
|
|
3589
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
3590
|
+
const captures = captureLaunches(t);
|
|
3591
|
+
const base = pinFixture("source-binding");
|
|
3592
|
+
base.writeStore({ pinned: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3593
|
+
base.writePin("pinned");
|
|
3594
|
+
bindSessionProfile("s1", "bound", { explore: { model: "openai/beta" } });
|
|
3595
|
+
const session = await launchSession(base);
|
|
3596
|
+
t.after(session.shutdown);
|
|
3597
|
+
await session.launch();
|
|
3598
|
+
assert.equal(captures[0]!.request.modelSource, "binding", "the explicit Enter binding wins per the chain precedence");
|
|
3599
|
+
assert.equal(captures[0]!.record.modelSource, "binding", "the record copies the request's layer token");
|
|
3600
|
+
});
|
|
3601
|
+
|
|
3602
|
+
test("a frozen global-active launch records frozen:global as the model source", async t => {
|
|
3603
|
+
const captures = captureLaunches(t);
|
|
3604
|
+
const base = pinFixture("source-freeze-global");
|
|
3605
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3606
|
+
const session = await launchSession(base);
|
|
3607
|
+
t.after(session.shutdown);
|
|
3608
|
+
await session.launch();
|
|
3609
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:global");
|
|
3610
|
+
});
|
|
3611
|
+
|
|
3612
|
+
test("a frozen local-pin launch records frozen:local as the model source", async t => {
|
|
3613
|
+
const captures = captureLaunches(t);
|
|
3614
|
+
const base = pinFixture("source-freeze-local");
|
|
3615
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3616
|
+
base.writePin("frontier");
|
|
3617
|
+
const session = await launchSession(base);
|
|
3618
|
+
t.after(session.shutdown);
|
|
3619
|
+
await session.launch();
|
|
3620
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:local");
|
|
3621
|
+
});
|
|
3622
|
+
|
|
3623
|
+
test("a session frozen on a non-Git declaration records frozen:repo as the model source", async t => {
|
|
3624
|
+
const captures = captureLaunches(t);
|
|
3625
|
+
const base = pinFixture("source-freeze-declaration");
|
|
3626
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3627
|
+
base.writeDeclaration("declared");
|
|
3628
|
+
const session = await launchNonGitSession(base);
|
|
3629
|
+
t.after(session.shutdown);
|
|
3630
|
+
await session.launch();
|
|
3631
|
+
assert.equal(captures[0]!.request.modelSource, "frozen:repo");
|
|
3632
|
+
});
|
|
3633
|
+
|
|
3634
|
+
test("an agent outside every profile records its frontmatter as the model source", async t => {
|
|
3635
|
+
const captures = captureLaunches(t);
|
|
3636
|
+
const base = pinFixture("source-definition");
|
|
3637
|
+
// The pinned profile replaces routing wholesale and does not name explore,
|
|
3638
|
+
// so explore's model comes from its own frontmatter.
|
|
3639
|
+
base.writeStore({ helper: { model: "openai/beta" } });
|
|
3640
|
+
base.writePin("helper");
|
|
3641
|
+
const session = await launchSession(base);
|
|
3642
|
+
t.after(session.shutdown);
|
|
3643
|
+
await session.launch();
|
|
3644
|
+
assert.equal(captures[0]!.request.modelSource, "definition");
|
|
3645
|
+
});
|
|
3646
|
+
|
|
3647
|
+
test("a follow launch through the globally active profile records profile as the model source", async t => {
|
|
3648
|
+
const captures = captureLaunches(t);
|
|
3649
|
+
const base = pinFixture("source-follow-global");
|
|
3650
|
+
base.writeStore(DRIFT_PROFILES, "frontier");
|
|
3651
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3652
|
+
t.after(session.shutdown);
|
|
3653
|
+
await session.launch();
|
|
3654
|
+
assert.equal(captures[0]!.request.modelSource, "profile");
|
|
3655
|
+
});
|
|
3656
|
+
|
|
3657
|
+
test("a follow launch through the local pin records pin:local as the model source", async t => {
|
|
3658
|
+
const captures = captureLaunches(t);
|
|
3659
|
+
const base = pinFixture("source-follow-pin");
|
|
3660
|
+
base.writeStore(DRIFT_PROFILES);
|
|
3661
|
+
base.writePin("frontier");
|
|
3662
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3663
|
+
t.after(session.shutdown);
|
|
3664
|
+
await session.launch();
|
|
3665
|
+
assert.equal(captures[0]!.request.modelSource, "pin:local");
|
|
3666
|
+
});
|
|
3667
|
+
|
|
3668
|
+
test("a follow launch through a committed repository declaration records pin:repo as the model source", async t => {
|
|
3669
|
+
const captures = captureLaunches(t);
|
|
3670
|
+
const base = pinFixture("source-follow-declaration");
|
|
3671
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3672
|
+
base.writeDeclaration("declared");
|
|
3673
|
+
const session = await launchSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3674
|
+
t.after(session.shutdown);
|
|
3675
|
+
await session.launch();
|
|
3676
|
+
assert.equal(captures[0]!.request.modelSource, "pin:repo");
|
|
3677
|
+
});
|
|
3678
|
+
|
|
3679
|
+
test("a follow launch through a non-Git repository declaration records declaration as the model source", async t => {
|
|
3680
|
+
const captures = captureLaunches(t);
|
|
3681
|
+
const base = pinFixture("source-follow-nongit");
|
|
3682
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha", thinking: "minimal" } } });
|
|
3683
|
+
base.writeDeclaration("declared");
|
|
3684
|
+
const session = await launchNonGitSession(base, { GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
3685
|
+
t.after(session.shutdown);
|
|
3686
|
+
await session.launch();
|
|
3687
|
+
assert.equal(captures[0]!.request.modelSource, "declaration");
|
|
3688
|
+
});
|
|
3689
|
+
|
|
3690
|
+
test("the agents task card shows the winning layer next to the model, and renders unchanged without one (#2180)", () => {
|
|
3691
|
+
const record = {
|
|
3692
|
+
id: "task", agent: "explore", mode: "task", prompt: "Map the repo", label: "Map", cwd: "/repo", parentSessionId: "s1",
|
|
3693
|
+
status: TASK_STATUS.RUNNING, createdAt: 60_000, startedAt: 60_000, endedAt: null, model: "nan/glm5.3",
|
|
3694
|
+
thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "", lastActivityAt: 60_000,
|
|
3695
|
+
turns: 0, toolCalls: 0, tokens: 0, cost: 0,
|
|
3696
|
+
} as TaskRecord;
|
|
3697
|
+
assert.equal(taskHeader({ ...record, modelSource: "binding" }, 61_000), "explore · running · nan/glm5.3 · binding · 1s");
|
|
3698
|
+
assert.equal(taskHeader(record, 61_000), "explore · running · nan/glm5.3 · 1s", "absent modelSource renders exactly as today");
|
|
3699
|
+
});
|
|
3700
|
+
|
|
3381
3701
|
test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
|
|
3382
3702
|
assert.equal(agentsEnabled({}), true);
|
|
3383
3703
|
assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
|
|
@@ -3456,7 +3776,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
3456
3776
|
assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
|
|
3457
3777
|
await tick();
|
|
3458
3778
|
assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
|
|
3459
|
-
assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+╮$/);
|
|
3779
|
+
assert.match(widget()![0], /^╭─ ❀ Agents ▾ Collapse · 1 active ─+╮$/);
|
|
3460
3780
|
assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · low · \d+s │$/);
|
|
3461
3781
|
harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
|
|
3462
3782
|
harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
|
|
@@ -3506,7 +3826,7 @@ test("running subagent_result polls hide only their tool chrome, not the model r
|
|
|
3506
3826
|
assert.match(renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(80).join("\n"), /Agent result.*All done\./s, "background completion card remains visible");
|
|
3507
3827
|
});
|
|
3508
3828
|
|
|
3509
|
-
test("the Agents
|
|
3829
|
+
test("the Agents card moves to the fullscreen rail like Todos and its header toggles collapse on left click", async () => {
|
|
3510
3830
|
const { pi, tools, fire } = fakePi();
|
|
3511
3831
|
const harness = deps();
|
|
3512
3832
|
gentleAgents(pi, {}, harness.deps);
|
|
@@ -3514,22 +3834,54 @@ test("the Agents widget never registers a sidebar rail part and stays visible ev
|
|
|
3514
3834
|
// (a host with no .terminal is already a passthrough); this is the host
|
|
3515
3835
|
// shape the fullscreen layout actually uses.
|
|
3516
3836
|
const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
|
|
3517
|
-
const { ctx,
|
|
3837
|
+
const { ctx, widgetComponent } = fakeContext(terminalTui);
|
|
3518
3838
|
await fire("session_start", ctx);
|
|
3519
3839
|
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
|
|
3840
|
+
await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Map tests/", label: "map test suites", mode: "background" }, undefined, undefined, ctx);
|
|
3520
3841
|
await tick();
|
|
3521
3842
|
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
assert.match(
|
|
3843
|
+
const component = widgetComponent()!;
|
|
3844
|
+
const bottom = component.render(72).map(stripAnsi);
|
|
3845
|
+
assert.match(bottom[0]!, /Agents ▾ Collapse/, "the header carries the same clickable control as Todos");
|
|
3846
|
+
assert.match(bottom.join("\n"), /explore map lib modules/);
|
|
3847
|
+
assert.match(bottom.join("\n"), /explore map test suites/);
|
|
3848
|
+
|
|
3525
3849
|
const state = sidebarState(terminalTui as unknown as TUI);
|
|
3526
|
-
|
|
3850
|
+
const rail = state.parts.get("agents");
|
|
3851
|
+
assert.ok(rail, "Agents claims the rail slot, so a wide fullscreen terminal shows it at the right");
|
|
3852
|
+
assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 2, "the rail lists every task");
|
|
3853
|
+
assert.ok(rail.digest?.(), "the rail declares a digest so its clock and counters repaint");
|
|
3527
3854
|
|
|
3528
|
-
//
|
|
3529
|
-
// condition sidebarPart used to suppress a registered bottom widget under.
|
|
3855
|
+
// While the fullscreen sidebar owns the host, the bottom copy steps aside.
|
|
3530
3856
|
state.active = true;
|
|
3531
3857
|
state.ownsHost = () => true;
|
|
3532
|
-
assert.
|
|
3858
|
+
assert.deepEqual(component.render(72), [], "the bottom card paints nothing while the rail shows it");
|
|
3859
|
+
state.active = false;
|
|
3860
|
+
|
|
3861
|
+
const event = (type: "press" | "click", button: "left" | "right", y = 0) => ({
|
|
3862
|
+
type, button, x: 1, y, screenX: 1, screenY: y, width: 72, height: bottom.length, shift: false, alt: false, ctrl: false,
|
|
3863
|
+
});
|
|
3864
|
+
assert.equal(component.handleMouse?.(event("click", "right")), undefined);
|
|
3865
|
+
assert.equal(component.handleMouse?.(event("click", "left", 1)), undefined);
|
|
3866
|
+
assert.match(stripAnsi(component.render(72)[0]!), /Agents ▾ Collapse/, "only a left click on the header toggles");
|
|
3867
|
+
assert.equal(component.handleMouse?.(event("click", "left"))?.handled, true);
|
|
3868
|
+
const collapsed = component.render(72).map(stripAnsi);
|
|
3869
|
+
assert.match(collapsed[0]!, /Agents ▸ Expand/);
|
|
3870
|
+
assert.doesNotMatch(collapsed.join("\n"), /map test suites/, "the collapsed card keeps one row");
|
|
3871
|
+
assert.equal(rail.render(46).filter((line) => /explore/.test(stripAnsi(line))).length, 1, "both surfaces share the collapsed state");
|
|
3872
|
+
});
|
|
3873
|
+
|
|
3874
|
+
test("the Agents rail digest stays stable while no task is running, so an idle card never rebuilds the rail every second", async () => {
|
|
3875
|
+
const { pi, fire } = fakePi();
|
|
3876
|
+
gentleAgents(pi, {}, deps().deps);
|
|
3877
|
+
const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
|
|
3878
|
+
const { ctx, widgetComponent } = fakeContext(terminalTui);
|
|
3879
|
+
await fire("session_start", ctx);
|
|
3880
|
+
widgetComponent();
|
|
3881
|
+
const rail = sidebarState(terminalTui as unknown as TUI).parts.get("agents")!;
|
|
3882
|
+
// The harness clock advances on every read, so three digests span more than a second.
|
|
3883
|
+
const digests = [rail.digest!(), rail.digest!(), rail.digest!()];
|
|
3884
|
+
assert.deepEqual(new Set(digests).size, 1, "the clock only enters the digest while a task runs");
|
|
3533
3885
|
});
|
|
3534
3886
|
|
|
3535
3887
|
test("background runs return at once; status, result, send_message, cancel, and continue follow the task", async () => {
|
|
@@ -4272,6 +4624,7 @@ test("session transport startup failure cleans the constructed Windows-capable t
|
|
|
4272
4624
|
|
|
4273
4625
|
test("registered task aliases persist independently of human names and curated state", async () => {
|
|
4274
4626
|
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
4627
|
+
const timers = recordTimers(runtime.deps);
|
|
4275
4628
|
const profile = realpathSync(mkdtempSync(join(root, "aliases-runtime-")));
|
|
4276
4629
|
runtime.deps.agentHome = profile;
|
|
4277
4630
|
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/aliases.sock", createdAt: 1 };
|
|
@@ -4288,8 +4641,15 @@ test("registered task aliases persist independently of human names and curated s
|
|
|
4288
4641
|
gentleAgents(h.pi, {}, runtime.deps);
|
|
4289
4642
|
await h.fire("session_start", ctx);
|
|
4290
4643
|
const tool = h.tools.get("orchestrator_session_id")!;
|
|
4291
|
-
const declare = (params: unknown = {}, context = ctx) =>
|
|
4292
|
-
|
|
4644
|
+
const declare = async (params: unknown = {}, context = ctx) => {
|
|
4645
|
+
const result = await tool.execute("aliases", params, undefined, undefined, context);
|
|
4646
|
+
timers.run(400); // the activity publication is coalesced; close the window
|
|
4647
|
+
return result;
|
|
4648
|
+
};
|
|
4649
|
+
const aliases = () => {
|
|
4650
|
+
timers.run(400);
|
|
4651
|
+
return readDiscovery(profile, listPresence(profile).entries[0])?.aliases;
|
|
4652
|
+
};
|
|
4293
4653
|
const first = await declare({ subject: "Task A", state: { progress: "Starting", work: { area: "Auth" } } });
|
|
4294
4654
|
const initial = h.entries.at(-1)!;
|
|
4295
4655
|
assert.deepEqual(aliases(), { initialAlias: "Task A", currentAlias: "Task A" });
|
|
@@ -4339,6 +4699,7 @@ test("registered task aliases persist independently of human names and curated s
|
|
|
4339
4699
|
test("registered session identity declares subjects and refreshes canonical idle renames", async (t) => {
|
|
4340
4700
|
const h = fakePi();
|
|
4341
4701
|
const runtime = deps();
|
|
4702
|
+
const timers = recordTimers(runtime.deps);
|
|
4342
4703
|
const profile = realpathSync(mkdtempSync(join(root, "subject-runtime-")));
|
|
4343
4704
|
runtime.deps.agentHome = profile;
|
|
4344
4705
|
let ready = false;
|
|
@@ -4366,7 +4727,11 @@ test("registered session identity declares subjects and refreshes canonical idle
|
|
|
4366
4727
|
await h.fire("session_start", ctx);
|
|
4367
4728
|
await eventually(() => ready, "subject transport ready");
|
|
4368
4729
|
const tool = h.tools.get("orchestrator_session_id")!;
|
|
4369
|
-
const declare = (subject?: unknown, context = ctx) =>
|
|
4730
|
+
const declare = async (subject?: unknown, context = ctx) => {
|
|
4731
|
+
const result = await tool.execute("id", { subject }, undefined, undefined, context);
|
|
4732
|
+
timers.run(400); // the activity publication is coalesced; close the window
|
|
4733
|
+
return result;
|
|
4734
|
+
};
|
|
4370
4735
|
const initialWrites = h.entries.length;
|
|
4371
4736
|
await assert.rejects(() => tool.execute("invalid-work", { subject: "No effect", state: { work: { topic: "Login" } } }, undefined, undefined, ctx), /invalid/);
|
|
4372
4737
|
assert.equal(name, "");
|
|
@@ -4384,6 +4749,7 @@ test("registered session identity declares subjects and refreshes canonical idle
|
|
|
4384
4749
|
{ kind: "issue", repository: "github.com/Owner/Repo", id: "12" },
|
|
4385
4750
|
] };
|
|
4386
4751
|
await tool.execute("publish", { state: { objective: "Verify auth", decisions: "Advisory only", work } }, undefined, undefined, ctx);
|
|
4752
|
+
timers.run(400);
|
|
4387
4753
|
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
|
|
4388
4754
|
assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.work, work);
|
|
4389
4755
|
const published = h.entries.at(-1)!;
|
|
@@ -4405,6 +4771,7 @@ test("registered session identity declares subjects and refreshes canonical idle
|
|
|
4405
4771
|
assert.equal(readDiscovery(profile, renamed)?.scope?.host.resolvedAt, readDiscovery(profile, before)?.scope?.host.resolvedAt);
|
|
4406
4772
|
registrations.push({ ...registrations[0], data: { ...registrations[0].data, root: join(cwd, "registered") } });
|
|
4407
4773
|
h.pi.events.emit(SESSION_WORKTREE_CHANGED, { sessionId: "s1" });
|
|
4774
|
+
timers.run(400);
|
|
4408
4775
|
assert.deepEqual(readDiscovery(profile, listPresence(profile).entries[0])?.scope?.registered.map(fact => fact.root), [cwd, join(cwd, "registered")]);
|
|
4409
4776
|
await declare("Do not overwrite");
|
|
4410
4777
|
assert.deepEqual(names, ["Fix auth"]);
|
|
@@ -4415,10 +4782,12 @@ test("registered session identity declares subjects and refreshes canonical idle
|
|
|
4415
4782
|
await declare("😀".repeat(130));
|
|
4416
4783
|
assert.equal(Array.from(name).length, 120);
|
|
4417
4784
|
await tool.execute("withdraw", { state: null }, undefined, undefined, ctx);
|
|
4785
|
+
timers.run(400);
|
|
4418
4786
|
await h.fire("session_start", ctx, { reason: "reload" });
|
|
4419
4787
|
await eventually(() => readDiscovery(profile, listPresence(profile).entries[0])?.state?.state === null, "withdrawal reload");
|
|
4420
4788
|
Object.assign(ctx.sessionManager, { getBranch: () => [published] });
|
|
4421
4789
|
await h.fire("session_tree", ctx);
|
|
4790
|
+
timers.run(400);
|
|
4422
4791
|
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.state?.state?.objective, "Verify auth");
|
|
4423
4792
|
const sameId = fakeContext().ctx;
|
|
4424
4793
|
Object.assign(sameId.sessionManager, { getSessionName: () => name, getBranch: () => [] });
|
|
@@ -4427,6 +4796,7 @@ test("registered session identity declares subjects and refreshes canonical idle
|
|
|
4427
4796
|
const replacement = fakeContext().ctx;
|
|
4428
4797
|
Object.assign(replacement.sessionManager, { getSessionId: () => "s2", getSessionName: () => "Replacement" });
|
|
4429
4798
|
await h.fire("session_start", replacement, { reason: "new" });
|
|
4799
|
+
timers.run(400); // the post-transport-start publication is coalesced; close the window
|
|
4430
4800
|
heartbeats[0]();
|
|
4431
4801
|
assert.equal(listPresence(profile).entries.length, 1);
|
|
4432
4802
|
assert.equal(listPresence(profile).entries[0].label, "Replacement");
|
|
@@ -4562,6 +4932,66 @@ test("registered orchestrator_list joins peer metadata without child launches or
|
|
|
4562
4932
|
} finally { publisher.dispose(); }
|
|
4563
4933
|
});
|
|
4564
4934
|
|
|
4935
|
+
test("orchestrator_list surfaces the real session-presence failure code", async () => {
|
|
4936
|
+
const h = fakePi();
|
|
4937
|
+
const runtime = deps();
|
|
4938
|
+
const profile = realpathSync(mkdtempSync(join(root, "discovery-failure-")));
|
|
4939
|
+
let ready = false;
|
|
4940
|
+
const registry = { list: async () => [], listActivations: async () => { throw new SessionPresenceError("busy", "presence registry is busy"); } };
|
|
4941
|
+
runtime.deps.agentHome = profile;
|
|
4942
|
+
runtime.deps.sessionTransport = {
|
|
4943
|
+
createRegistry: async () => registry,
|
|
4944
|
+
createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
|
|
4945
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
|
|
4946
|
+
};
|
|
4947
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4948
|
+
const { ctx } = fakeContext();
|
|
4949
|
+
try {
|
|
4950
|
+
await h.fire("session_start", ctx);
|
|
4951
|
+
await eventually(() => ready, "discovery transport ready");
|
|
4952
|
+
const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
|
|
4953
|
+
assert.match(result.content[0].text, /session discovery is unavailable \(busy\)/);
|
|
4954
|
+
assert.equal((result.details as { error?: string }).error, "unavailable");
|
|
4955
|
+
assert.equal((result.details as { reason?: string }).reason, "busy");
|
|
4956
|
+
} finally {
|
|
4957
|
+
await h.fire("session_shutdown", ctx);
|
|
4958
|
+
rmSync(profile, { recursive: true, force: true });
|
|
4959
|
+
}
|
|
4960
|
+
});
|
|
4961
|
+
|
|
4962
|
+
test("orchestrator_list labels a live peer that predates discovery publication (#2305)", async () => {
|
|
4963
|
+
const h = fakePi();
|
|
4964
|
+
const runtime = deps();
|
|
4965
|
+
const profile = realpathSync(mkdtempSync(join(root, "discovery-legacy-")));
|
|
4966
|
+
const peer = { version: 1 as const, sessionId: "peer", endpoint: "/fixture/peer.sock", createdAt: 1 };
|
|
4967
|
+
// A peer running pre-publication code heartbeats its schema-1 header only.
|
|
4968
|
+
const publisher = PresencePublisher.start({ profile, sessionId: "peer", label: "gentle-shell", activity: [] });
|
|
4969
|
+
try {
|
|
4970
|
+
let ready = false;
|
|
4971
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
4972
|
+
runtime.deps.agentHome = profile;
|
|
4973
|
+
runtime.deps.sessionTransport = {
|
|
4974
|
+
createRegistry: async () => registry,
|
|
4975
|
+
createListener: () => ({ registry, start: async () => { ready = true; }, close: async () => {} }),
|
|
4976
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("no messages expected"); } }),
|
|
4977
|
+
};
|
|
4978
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4979
|
+
const { ctx } = fakeContext();
|
|
4980
|
+
await h.fire("session_start", ctx);
|
|
4981
|
+
await eventually(() => ready, "discovery transport ready");
|
|
4982
|
+
const result = await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx);
|
|
4983
|
+
assert.equal(result.content[0].text, "Advertised sessions (reachability is unknown):\n"
|
|
4984
|
+
+ "- peer · gentle-shell · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)"
|
|
4985
|
+
+ " · repository: not published · metadata: legacy\n recorded catalog: unknown");
|
|
4986
|
+
// Consultation stays fail-closed: a legacy label is display-only.
|
|
4987
|
+
const consult = await h.tools.get("orchestrator_consult")!.execute("consult", { recipient_session_id: "peer" }, undefined, undefined, ctx);
|
|
4988
|
+
assert.equal(JSON.parse(consult.content[0].text).status, "unavailable");
|
|
4989
|
+
assert.equal(runtime.spawned.length, 0);
|
|
4990
|
+
assert.equal(h.sent.length, 0);
|
|
4991
|
+
await h.fire("session_shutdown", ctx);
|
|
4992
|
+
} finally { publisher.dispose(); }
|
|
4993
|
+
});
|
|
4994
|
+
|
|
4565
4995
|
test("registered reasoning and revocation use live SDK host and published source only", async () => {
|
|
4566
4996
|
const h = fakePi(), runtime = deps();
|
|
4567
4997
|
const profile = realpathSync(mkdtempSync(join(root, "reasoning-runtime-")));
|
|
@@ -6500,3 +6930,197 @@ test("a human stop of a monitor from /gentle:jobs delivers its pending lines, th
|
|
|
6500
6930
|
jobs.cleanup();
|
|
6501
6931
|
}
|
|
6502
6932
|
});
|
|
6933
|
+
|
|
6934
|
+
// Harness roadmap T6: todo progress reaches peers without a model turn spent on
|
|
6935
|
+
// orchestrator_session_id, and never replaces the owner's curated fields.
|
|
6936
|
+
test("todo progress publishes merged into the owner's curated state, only for this session", async () => {
|
|
6937
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
6938
|
+
const timers = recordTimers(runtime.deps);
|
|
6939
|
+
const profile = realpathSync(mkdtempSync(join(root, "todo-progress-")));
|
|
6940
|
+
runtime.deps.agentHome = profile;
|
|
6941
|
+
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress.sock", createdAt: 1 };
|
|
6942
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
6943
|
+
runtime.deps.sessionTransport = {
|
|
6944
|
+
createRegistry: async () => registry,
|
|
6945
|
+
createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
|
|
6946
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
|
|
6947
|
+
};
|
|
6948
|
+
Object.assign(h.pi, { setSessionName() {} });
|
|
6949
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
|
|
6950
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
6951
|
+
await h.fire("session_start", ctx);
|
|
6952
|
+
const published = () => {
|
|
6953
|
+
timers.run(400); // the activity publication is coalesced; close the window
|
|
6954
|
+
return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
|
|
6955
|
+
};
|
|
6956
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 2, current: "Write parser" });
|
|
6957
|
+
// PR #2290 review: a todo title is private work text; peers see counts only.
|
|
6958
|
+
assert.deepEqual(published(), { progress: "0/2 done" }, "progress publishes before any curated state, without the todo title");
|
|
6959
|
+
assert.doesNotMatch(JSON.stringify(readDiscovery(profile, listPresence(profile).entries[0])), /Write parser/, "the todo title never reaches peers");
|
|
6960
|
+
await h.tools.get("orchestrator_session_id")!.execute("own", { subject: "Ledger", state: { objective: "Ship the ledger", decisions: "inline" } }, undefined, undefined, ctx);
|
|
6961
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 2, current: null });
|
|
6962
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline", progress: "1/2 done" });
|
|
6963
|
+
assert.equal(readDiscovery(profile, listPresence(profile).entries[0])?.aliases?.currentAlias, "Ledger", "the subject survives");
|
|
6964
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "other", done: 2, total: 2, current: null });
|
|
6965
|
+
assert.equal(published()?.progress, "1/2 done", "another session's todo is ignored");
|
|
6966
|
+
// R1 review advisory: a cleared list published "0/0 done" as if it were progress.
|
|
6967
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 0, total: 0, current: null });
|
|
6968
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", decisions: "inline" }, "a cleared list removes the progress line and keeps curated fields");
|
|
6969
|
+
// R1 review advisory: a withdrawn state must stay withdrawn until the owner publishes again.
|
|
6970
|
+
await h.tools.get("orchestrator_session_id")!.execute("withdraw", { state: null }, undefined, undefined, ctx);
|
|
6971
|
+
assert.equal(published(), null);
|
|
6972
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Docs" });
|
|
6973
|
+
assert.equal(published(), null, "automatic progress does not republish a withdrawn state");
|
|
6974
|
+
await h.tools.get("orchestrator_session_id")!.execute("own", { state: { objective: "Ship v2" } }, undefined, undefined, ctx);
|
|
6975
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
6976
|
+
assert.deepEqual(published(), { objective: "Ship v2", progress: "2/3 done" }, "progress resumes once the owner publishes again");
|
|
6977
|
+
});
|
|
6978
|
+
|
|
6979
|
+
// PR #2290 review: an owner publication without `progress` replaced the automatic
|
|
6980
|
+
// progress line, so peers lost it until the next todo write.
|
|
6981
|
+
test("an owner publication without progress keeps the automatic progress; explicit progress and null still win", async () => {
|
|
6982
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
6983
|
+
const timers = recordTimers(runtime.deps);
|
|
6984
|
+
const profile = realpathSync(mkdtempSync(join(root, "todo-progress-merge-")));
|
|
6985
|
+
runtime.deps.agentHome = profile;
|
|
6986
|
+
const peer = { version: 1 as const, sessionId: "s1", endpoint: "/fixture/todo-progress-merge.sock", createdAt: 1 };
|
|
6987
|
+
const registry = { list: async () => [], listActivations: async () => [peer] };
|
|
6988
|
+
runtime.deps.sessionTransport = {
|
|
6989
|
+
createRegistry: async () => registry,
|
|
6990
|
+
createListener: () => ({ registry, record: peer, start: async () => {}, close: async () => {} }),
|
|
6991
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("unexpected message"); } }),
|
|
6992
|
+
};
|
|
6993
|
+
Object.assign(h.pi, { setSessionName() {} });
|
|
6994
|
+
Object.assign(ctx.sessionManager, { getSessionName: () => "", getBranch: () => h.entries });
|
|
6995
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
6996
|
+
await h.fire("session_start", ctx);
|
|
6997
|
+
const published = () => {
|
|
6998
|
+
timers.run(400); // the activity publication is coalesced; close the window
|
|
6999
|
+
return readDiscovery(profile, listPresence(profile).entries[0])?.state?.state;
|
|
7000
|
+
};
|
|
7001
|
+
const own = (state: unknown) => h.tools.get("orchestrator_session_id")!.execute("own", { state }, undefined, undefined, ctx);
|
|
7002
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 3, current: "Parser" });
|
|
7003
|
+
await own({ objective: "Ship the ledger" });
|
|
7004
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", progress: "1/3 done" }, "omitted progress keeps the automatic line");
|
|
7005
|
+
await own({ objective: "Ship the ledger", progress: "Reviewing the parser" });
|
|
7006
|
+
assert.deepEqual(published(), { objective: "Ship the ledger", progress: "Reviewing the parser" }, "explicit owner progress wins");
|
|
7007
|
+
await own({ objective: "Ship the ledger" });
|
|
7008
|
+
assert.deepEqual(published(), { objective: "Ship the ledger" }, "owner progress is replaced like any curated field");
|
|
7009
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 2, total: 3, current: null });
|
|
7010
|
+
await own(null);
|
|
7011
|
+
assert.equal(published(), null, "null withdraws everything");
|
|
7012
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 3, total: 3, current: null });
|
|
7013
|
+
assert.equal(published(), null, "the withdrawal holds against automatic progress");
|
|
7014
|
+
await own({ objective: "Ship v2" });
|
|
7015
|
+
assert.deepEqual(published(), { objective: "Ship v2" }, "a withdrawn progress line is not merged back");
|
|
7016
|
+
h.pi.events.emit(TODO_PROGRESS_EVENT, { sessionId: "s1", done: 1, total: 4, current: null });
|
|
7017
|
+
const full = "x".repeat(2048);
|
|
7018
|
+
await own({ objective: full });
|
|
7019
|
+
assert.deepEqual(published(), { objective: full }, "a merge over the state budget publishes the owner's state as given");
|
|
7020
|
+
});
|
|
7021
|
+
|
|
7022
|
+
// Issue #2196 (PR #2233 maintainer decision): at launch, a model_profiles key
|
|
7023
|
+
// that names no discovered agent but suggests the launched one is reported
|
|
7024
|
+
// instead of being silently ignored while the child inherits the orchestrator
|
|
7025
|
+
// model. An omitted or matched role keeps today's inheritance untouched.
|
|
7026
|
+
test("subagent_run warns when a model profile key suggests the launched agent", async () => {
|
|
7027
|
+
const child = fakePi();
|
|
7028
|
+
const runtime = deps();
|
|
7029
|
+
gentleAgents(child.pi, {}, runtime.deps);
|
|
7030
|
+
const { ctx, dialogs } = fakeContext();
|
|
7031
|
+
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
|
7032
|
+
writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
|
|
7033
|
+
await child.fire("session_start", ctx);
|
|
7034
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "warn", mode: "background" }, undefined, undefined, ctx);
|
|
7035
|
+
await tick();
|
|
7036
|
+
assert.equal(runtime.children.length, 1, "the launch itself still proceeds");
|
|
7037
|
+
assert.equal(dialogs.filter((message) => /"gentle-ai-explore" did not match any agent, did you mean explore\?/.test(message)).length, 1);
|
|
7038
|
+
|
|
7039
|
+
// A key that matches the agent exactly never warns, and the fixture's own
|
|
7040
|
+
// global effort profile (explore) stays matched.
|
|
7041
|
+
dialogs.length = 0;
|
|
7042
|
+
writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha" } } })}\n`);
|
|
7043
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
|
|
7044
|
+
await tick();
|
|
7045
|
+
assert.equal(dialogs.length, 0, "a matched key is silent");
|
|
7046
|
+
});
|
|
7047
|
+
|
|
7048
|
+
// CodeRabbit follow-up on PR #2397: a key that names another discovered agent
|
|
7049
|
+
// is valid, so launching a different agent whose name it merely resembles
|
|
7050
|
+
// (explore vs gentle-ai-explore) must not label it unmatched.
|
|
7051
|
+
test("subagent_run does not warn about a key that matches another discovered agent", async (t) => {
|
|
7052
|
+
const child = fakePi();
|
|
7053
|
+
const runtime = deps();
|
|
7054
|
+
gentleAgents(child.pi, {}, runtime.deps);
|
|
7055
|
+
const { ctx, dialogs } = fakeContext();
|
|
7056
|
+
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
|
|
7057
|
+
const projectAgent = join(cwd, ".pi", "agents", "gentle-ai-explore.md");
|
|
7058
|
+
const subagents = join(cwd, ".pi", "subagents.json");
|
|
7059
|
+
t.after(() => rmSync(projectAgent, { force: true }));
|
|
7060
|
+
t.after(() => rmSync(subagents, { force: true }));
|
|
7061
|
+
writeFileSync(projectAgent, "---\ndescription: packaged explore\ntools: [read]\n---\nYou map things.");
|
|
7062
|
+
writeFileSync(subagents, `${JSON.stringify({ model_profiles: { "gentle-ai-explore": { model: "openai/alpha" } } })}\n`);
|
|
7063
|
+
await child.fire("session_start", ctx);
|
|
7064
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "quiet", mode: "background" }, undefined, undefined, ctx);
|
|
7065
|
+
await tick();
|
|
7066
|
+
assert.equal(runtime.children.length, 1, "the launch proceeds");
|
|
7067
|
+
assert.equal(dialogs.filter((message) => /did not match any agent/.test(message)).length, 0, "a key naming a discovered agent is never unmatched");
|
|
7068
|
+
});
|
|
7069
|
+
|
|
7070
|
+
// Issue #2400: a model_profiles key that matches no discovered agent and has no
|
|
7071
|
+
// did-you-mean suggestion (a typo such as `explor`) was still silent, because
|
|
7072
|
+
// the launch warning only named keys suggesting the launched agent. The first
|
|
7073
|
+
// launch of a session now lists every such key once; later launches in the
|
|
7074
|
+
// same session stay quiet, and the key is never routed.
|
|
7075
|
+
test("subagent_run warns once per session about model profile keys with no suggestion", async (t) => {
|
|
7076
|
+
const child = fakePi();
|
|
7077
|
+
const runtime = deps();
|
|
7078
|
+
const captures = captureLaunches(t);
|
|
7079
|
+
gentleAgents(child.pi, {}, runtime.deps);
|
|
7080
|
+
const { ctx, dialogs } = fakeContext();
|
|
7081
|
+
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
|
7082
|
+
const subagents = join(cwd, ".pi", "subagents.json");
|
|
7083
|
+
t.after(() => rmSync(subagents, { force: true }));
|
|
7084
|
+
writeFileSync(subagents, `${JSON.stringify({ model_profiles: { explor: { model: "openai/alpha" }, "sdd-desing": { model: "openai/beta" } } })}\n`);
|
|
7085
|
+
await child.fire("session_start", ctx);
|
|
7086
|
+
const unmatched = () => dialogs.filter((message) => /did not match any agent and was ignored/.test(message));
|
|
7087
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "first", mode: "background" }, undefined, undefined, ctx);
|
|
7088
|
+
await tick();
|
|
7089
|
+
assert.equal(unmatched().length, 1, "the first launch names the unmatched keys");
|
|
7090
|
+
assert.match(unmatched()[0]!, /"explor" did not match any agent and was ignored\./);
|
|
7091
|
+
assert.match(unmatched()[0]!, /"sdd-desing" did not match any agent and was ignored\./);
|
|
7092
|
+
assert.equal(dialogs.filter((message) => /did you mean/.test(message)).length, 0, "a key without a suggestion never claims one");
|
|
7093
|
+
assert.equal(captures[0]!.request.modelSource === "profile", false, "an unmatched key is never routed");
|
|
7094
|
+
|
|
7095
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "second", mode: "background" }, undefined, undefined, ctx);
|
|
7096
|
+
await tick();
|
|
7097
|
+
assert.equal(unmatched().length, 1, "later launches in the same session do not repeat it");
|
|
7098
|
+
|
|
7099
|
+
// A different parent session is told again.
|
|
7100
|
+
const other = fakeContext();
|
|
7101
|
+
other.ctx.sessionManager.getSessionId = () => "s2";
|
|
7102
|
+
await child.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "other", mode: "background" }, undefined, undefined, other.ctx);
|
|
7103
|
+
await tick();
|
|
7104
|
+
assert.equal(other.dialogs.filter((message) => /"explor" did not match any agent and was ignored\./.test(message)).length, 1);
|
|
7105
|
+
});
|
|
7106
|
+
|
|
7107
|
+
// Cortex QA adversarial follow-up (end-to-end pin for the #2196 fix): a key
|
|
7108
|
+
// that matches a discovered agent must not only stay silent, it must actually
|
|
7109
|
+
// route: the task request carries the profile model and thinking, so the child
|
|
7110
|
+
// launches with --model exactly as the issue's expected behavior describes.
|
|
7111
|
+
test("a matched model_profiles key routes the task request model end to end", async (t) => {
|
|
7112
|
+
const h = fakePi();
|
|
7113
|
+
const runtime = deps();
|
|
7114
|
+
const captures = captureLaunches(t);
|
|
7115
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
7116
|
+
const { ctx } = fakeContext();
|
|
7117
|
+
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
|
7118
|
+
writeFileSync(join(cwd, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explore: { model: "openai/alpha", effort: "high" } } })}\n`);
|
|
7119
|
+
await h.fire("session_start", ctx);
|
|
7120
|
+
await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "route", mode: "background" }, undefined, undefined, ctx);
|
|
7121
|
+
await tick();
|
|
7122
|
+
assert.equal(captures.length, 1);
|
|
7123
|
+
assert.deepEqual(captures[0]!.request.model, { provider: "openai", id: "alpha" }, "the matched key routes the configured model into the launch");
|
|
7124
|
+
assert.equal(captures[0]!.request.thinking, "high");
|
|
7125
|
+
assert.equal(captures[0]!.request.modelSource, "profile");
|
|
7126
|
+
});
|