gentle-pi 3.6.0 → 4.0.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +95 -30
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +117 -235
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +509 -275
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +105 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +53 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-change-capture.ts +10 -1
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +53 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +400 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1411 -256
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-change-capture.test.ts +12 -1
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, 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";
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { pendingReviewMutation, pendingReviewMutationProfiles, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
8
9
|
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
9
10
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
10
|
-
import type
|
|
11
|
-
import test, { after, afterEach, mock } from "node:test";
|
|
11
|
+
import { SessionChanges, type SessionChangeEvidence } from "../lib/session-changes.ts";
|
|
12
|
+
import test, { after, afterEach, before, mock } from "node:test";
|
|
12
13
|
import type { TestContext } from "node:test";
|
|
13
|
-
import type
|
|
14
|
+
import { generateUnifiedPatch, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
15
16
|
import { sidebarState } from "../lib/shell-sidebar.ts";
|
|
16
|
-
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
|
|
17
|
+
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";
|
|
17
18
|
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
|
|
18
19
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
|
|
19
20
|
import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
|
|
@@ -24,9 +25,14 @@ import { PresenceCursor, PresencePublisher, listPresence, readActivity } from ".
|
|
|
24
25
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
25
26
|
import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
26
27
|
import { AgentRunner } from "../lib/agents-runner.ts";
|
|
27
|
-
import
|
|
28
|
+
import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts";
|
|
28
29
|
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
29
|
-
import {
|
|
30
|
+
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
31
|
+
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
32
|
+
// panels unless a test switches the style itself.
|
|
33
|
+
const initialCardStyle = cardStyle();
|
|
34
|
+
before(() => setCardStyle(CARD_STYLE.NEON));
|
|
35
|
+
after(() => setCardStyle(initialCardStyle));
|
|
30
36
|
|
|
31
37
|
// Gentle Agents extension: the subagent_* tools drive isolated pi children,
|
|
32
38
|
// the card above the editor follows the store, and dialogs reach the host UI.
|
|
@@ -38,19 +44,11 @@ interface Registered {
|
|
|
38
44
|
name: string;
|
|
39
45
|
execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
|
|
40
46
|
renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
|
|
47
|
+
renderResult(result: { content: Array<{ type: string; text: string }>; details: Record<string, unknown> }, options: { expanded: boolean }, theme: unknown): { render(width: number): string[] };
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
44
51
|
const fakeTui = { requestRender() {} };
|
|
45
|
-
const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
|
|
46
|
-
executionMode: "auto",
|
|
47
|
-
artifactStore: "openspec",
|
|
48
|
-
chainedPrStrategy: "ask-on-risk",
|
|
49
|
-
reviewBudgetLines: 400,
|
|
50
|
-
engramAvailable: false,
|
|
51
|
-
prompted: true,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
52
|
const inertSessionTransport: SessionTransportFactory = {
|
|
55
53
|
createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
|
|
56
54
|
createListener: (registry) => ({ registry, start: async () => {}, close: async () => {} }),
|
|
@@ -118,6 +116,15 @@ function fakePi() {
|
|
|
118
116
|
const shortcuts = new Map<string, { description: string; handler(ctx: ExtensionContext): Promise<void> }>();
|
|
119
117
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
|
|
120
118
|
const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
|
|
119
|
+
// Idle wake-ups go through pi.sendUserMessage; `delivery` records the
|
|
120
|
+
// interleaving of custom messages and wake-ups so ordering stays provable.
|
|
121
|
+
const userMessages: Array<{ content: unknown; options: Record<string, unknown> | undefined }> = [];
|
|
122
|
+
const delivery: string[] = [];
|
|
123
|
+
// A live idle flag shared by every context fired through this host, like
|
|
124
|
+
// Pi's ctx.isIdle(): busy from agent_start until agent_settled. Tests can
|
|
125
|
+
// force it to simulate compaction or other non-run busy states.
|
|
126
|
+
let parentIdle = true;
|
|
127
|
+
const setIdle = (idle: boolean) => { parentIdle = idle; };
|
|
121
128
|
const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
|
|
122
129
|
const entryRenderers = new Map<string, (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
|
|
123
130
|
const entries: Array<{ type: string; customType: string; data: unknown }> = [];
|
|
@@ -132,7 +139,8 @@ function fakePi() {
|
|
|
132
139
|
return () => { set.delete(listener); };
|
|
133
140
|
},
|
|
134
141
|
},
|
|
135
|
-
sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
|
|
142
|
+
sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => { sent.push({ message, options }); delivery.push(`custom:${String(message.customType)}`); },
|
|
143
|
+
sendUserMessage: (content: unknown, options?: Record<string, unknown>) => { userMessages.push({ content, options }); delivery.push("user"); },
|
|
136
144
|
registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
|
|
137
145
|
registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer),
|
|
138
146
|
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
|
|
@@ -148,6 +156,9 @@ function fakePi() {
|
|
|
148
156
|
};
|
|
149
157
|
const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
|
|
150
158
|
const results: unknown[] = [];
|
|
159
|
+
if (!("isIdle" in ctx)) Object.assign(ctx, { isIdle: () => parentIdle });
|
|
160
|
+
if (event === "agent_start") parentIdle = false;
|
|
161
|
+
else if (event === "agent_settled") parentIdle = true;
|
|
151
162
|
try {
|
|
152
163
|
for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx));
|
|
153
164
|
return results;
|
|
@@ -161,10 +172,10 @@ function fakePi() {
|
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
};
|
|
164
|
-
return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
|
|
175
|
+
return { pi, tools, shortcuts, commands, fire, sent, userMessages, delivery, setIdle, renderers, entryRenderers, entries, events, listeners };
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
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() {} }) {
|
|
178
|
+
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]) {
|
|
168
179
|
const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
|
|
169
180
|
const dialogs: string[] = [];
|
|
170
181
|
const overlays: Overlay[] = [];
|
|
@@ -193,7 +204,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
193
204
|
},
|
|
194
205
|
select: async (title: string, options: string[]) => {
|
|
195
206
|
dialogs.push(`select:${title}:${options.join("|")}`);
|
|
196
|
-
return options
|
|
207
|
+
return selectResult(title, options);
|
|
197
208
|
},
|
|
198
209
|
confirm: async (title: string, message: string) => {
|
|
199
210
|
dialogs.push(`confirm:${title}:${message}`);
|
|
@@ -213,6 +224,29 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
213
224
|
return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
|
|
214
225
|
}
|
|
215
226
|
|
|
227
|
+
// Records deps.schedule calls so a test fires exactly the timers it means to;
|
|
228
|
+
// unrelated runner timers stay pending.
|
|
229
|
+
function recordTimers(target: Partial<AgentsDeps>) {
|
|
230
|
+
const pending: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
|
|
231
|
+
target.schedule = (fn, ms) => {
|
|
232
|
+
const timer = { fn, ms, cancelled: false };
|
|
233
|
+
pending.push(timer);
|
|
234
|
+
return () => { timer.cancelled = true; };
|
|
235
|
+
};
|
|
236
|
+
const due = (ms: number) => pending.filter((timer) => timer.ms === ms && !timer.cancelled);
|
|
237
|
+
return {
|
|
238
|
+
pending: (ms: number) => due(ms).length,
|
|
239
|
+
run: (ms: number) => {
|
|
240
|
+
const timers = due(ms);
|
|
241
|
+
for (const timer of timers) {
|
|
242
|
+
pending.splice(pending.indexOf(timer), 1);
|
|
243
|
+
timer.fn();
|
|
244
|
+
}
|
|
245
|
+
return timers.length;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
216
250
|
function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: string[][] } {
|
|
217
251
|
const children: FakeChild[] = [];
|
|
218
252
|
const spawned: string[][] = [];
|
|
@@ -296,7 +330,8 @@ test("cache warming follows actual Gentle Agents ownership and completion lifecy
|
|
|
296
330
|
assert.equal(h.sent.length, 1);
|
|
297
331
|
assert.equal(h.sent[0].message.customType, "gentle-agents.result");
|
|
298
332
|
assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId));
|
|
299
|
-
assert.equal(h.sent[0].options.triggerTurn,
|
|
333
|
+
assert.equal(h.sent[0].options.triggerTurn, false, "an idle parent stores the completion without a direct turn");
|
|
334
|
+
assert.equal(h.userMessages.length, 1, "the idle parent is woken once through the prompt lifecycle");
|
|
300
335
|
assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work");
|
|
301
336
|
// Seed the actual history-restore path with a stale live-looking record:
|
|
302
337
|
// sharing the parent ID and running status must not confer ownership.
|
|
@@ -369,7 +404,8 @@ for (const mode of ["print", "tui", "rpc"] as const) {
|
|
|
369
404
|
assert.equal(h.sent[0].message.customType, "gentle-agents.result");
|
|
370
405
|
assert.equal(h.sent[0].message.content, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`);
|
|
371
406
|
assert.equal(h.sent[0].message.display, true);
|
|
372
|
-
assert.deepEqual(h.sent[0].options, {
|
|
407
|
+
assert.deepEqual(h.sent[0].options, { triggerTurn: false });
|
|
408
|
+
assert.deepEqual(h.delivery, ["custom:gentle-agents.result", "user"], "the idle wake follows the stored completion");
|
|
373
409
|
await h.fire("turn_end", ctx);
|
|
374
410
|
await h.fire("turn_end", ctx);
|
|
375
411
|
await tick();
|
|
@@ -1052,7 +1088,8 @@ test("child parent-message tooling admits notifications and the active parent pr
|
|
|
1052
1088
|
await tick();
|
|
1053
1089
|
assert.equal(parent.sent[0]?.message.content, "raw\u001B[2J text");
|
|
1054
1090
|
assert.equal(parent.sent[0]?.message.display, false, "ordinary child notifications remain model-visible but do not render in the transcript");
|
|
1055
|
-
assert.deepEqual(parent.sent[0]?.options, {
|
|
1091
|
+
assert.deepEqual(parent.sent[0]?.options, { triggerTurn: false });
|
|
1092
|
+
assert.deepEqual(parent.delivery, ["custom:gentle-agents.message", "user"], "an idle parent is woken after the stored notification");
|
|
1056
1093
|
const rendered = parent.renderers.get("gentle-agents.message")!(parent.sent[0]?.message, { expanded: true }, plainTheme).render(80).join("\n");
|
|
1057
1094
|
assert.match(rendered, /raw\\x1B\[2J text/);
|
|
1058
1095
|
(ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
|
|
@@ -1104,9 +1141,9 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
|
|
|
1104
1141
|
writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
|
|
1105
1142
|
gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile, runtimeMetricsPolicy: policy, metricsNow: () => clock, metricsSchedule });
|
|
1106
1143
|
const listenerCounts = () => [...h.listeners].map(([name, set]) => [name, set.size]);
|
|
1107
|
-
const initialListeners = listenerCounts();
|
|
1108
1144
|
await h.fire("session_start", context.ctx);
|
|
1109
|
-
const
|
|
1145
|
+
const initialListeners = listenerCounts();
|
|
1146
|
+
const result = h.tools.get("subagent_run")!.execute("call", { agent: "gentle-ai-worker", task: "private task\n## Allowed edit surfaces\nsrc/app.ts\n## Return\nReport", mode: "task" }, undefined, undefined, context.ctx);
|
|
1110
1147
|
await tick();
|
|
1111
1148
|
assert.equal(runtime.children.length, 1);
|
|
1112
1149
|
const child = runtime.children[0];
|
|
@@ -1140,7 +1177,7 @@ for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws"
|
|
|
1140
1177
|
Object.assign(fresh.pi, { events: h.pi.events });
|
|
1141
1178
|
gentleAgents(fresh.pi, env, { ...runtime.deps, env, runtimeMetricsPolicy: policy, metricsSchedule });
|
|
1142
1179
|
await fresh.fire("session_start", context.ctx);
|
|
1143
|
-
assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set");
|
|
1180
|
+
assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set, including visual preference updates");
|
|
1144
1181
|
await fresh.fire("session_shutdown", context.ctx);
|
|
1145
1182
|
assert.ok([...h.listeners.values()].every(set => set.size === 0));
|
|
1146
1183
|
assert.equal(renewalTimers.size, 0);
|
|
@@ -1445,96 +1482,16 @@ test("live-only directory traverses presence overflow, excludes expired and othe
|
|
|
1445
1482
|
await panel.opened;
|
|
1446
1483
|
});
|
|
1447
1484
|
|
|
1448
|
-
test("
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
|
|
1456
|
-
let childEnv: NodeJS.ProcessEnv = {};
|
|
1457
|
-
gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
|
|
1458
|
-
childEnv = options.env!;
|
|
1459
|
-
return runtime.deps.spawn!(command, args, options);
|
|
1460
|
-
} });
|
|
1461
|
-
const result = await fake.tools.get("subagent_run")!.execute("research", { agent: "sdd-research", task: "Research docs", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
1462
|
-
await tick();
|
|
1463
|
-
const argv = runtime.spawned[0];
|
|
1464
|
-
assert.equal(argv[argv.indexOf("--tools") + 1], "fetch_content,subagent_parent_message");
|
|
1465
|
-
assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
|
|
1466
|
-
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
|
|
1467
|
-
assert.equal(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact, undefined);
|
|
1468
|
-
assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
|
|
1469
|
-
assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
|
|
1470
|
-
assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
|
|
1471
|
-
assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /open-web: blocked/, "two reachable tools cannot admit open-web");
|
|
1472
|
-
runtime.children[0].emit({ type: "agent_settled" });
|
|
1473
|
-
await tick();
|
|
1474
|
-
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
1475
|
-
await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background" }, undefined, undefined, ctx);
|
|
1476
|
-
await tick();
|
|
1477
|
-
assert.equal(runtime.spawned.length, 2);
|
|
1478
|
-
assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
|
|
1479
|
-
assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "subagent_parent_message");
|
|
1480
|
-
assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
|
|
1481
|
-
fake.pi.getActiveTools = () => ["web_search"];
|
|
1482
|
-
runtime.children[1].emit({ type: "agent_settled" });
|
|
1483
|
-
await tick();
|
|
1484
|
-
const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
1485
|
-
await tick();
|
|
1486
|
-
assert.ok(!runtime.spawned[2].includes("--extension"));
|
|
1487
|
-
runtime.children[2].emit({ type: "agent_settled" });
|
|
1488
|
-
await tick();
|
|
1489
|
-
fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
|
|
1490
|
-
await fake.tools.get("subagent_continue")!.execute("corrected", { task_id: (denied.details.gentleAgents as { taskId: string }).taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
1491
|
-
await tick();
|
|
1492
|
-
assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
|
|
1493
|
-
await fake.fire("session_shutdown", ctx);
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
test("research registered continuation needs no prior artifact identity", async () => {
|
|
1497
|
-
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
1498
|
-
const home = join(root, "optional-research-home");
|
|
1499
|
-
mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
|
|
1500
|
-
writeFileSync(join(home, ".pi/agent/agents/sdd-research.md"), "---\nname: sdd-research\ntools: [read]\n---\nExplore a question.");
|
|
1501
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home });
|
|
1502
|
-
await h.fire("session_start", ctx);
|
|
1503
|
-
const first = await h.tools.get("subagent_run")!.execute("first", { agent: "sdd-research", task: "Inspect a question", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
|
|
1504
|
-
await tick();
|
|
1505
|
-
runtime.children[0].emit({ type: "agent_settled" });
|
|
1506
|
-
await tick();
|
|
1507
|
-
const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
|
|
1508
|
-
const next = await h.tools.get("subagent_continue")!.execute("next", { task_id: taskId, prompt: "Investigate the remaining question", mode: "background" }, undefined, undefined, ctx);
|
|
1509
|
-
await tick();
|
|
1510
|
-
assert.equal(runtime.spawned.length, 2, JSON.stringify(next));
|
|
1511
|
-
await h.fire("session_shutdown", ctx);
|
|
1512
|
-
});
|
|
1513
|
-
|
|
1514
|
-
test("research child inventory exposes remaining authorized tools", () => {
|
|
1515
|
-
const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
|
|
1516
|
-
for (const missing of [undefined, ...required]) {
|
|
1517
|
-
const hooks = new Map<string, (event: any) => any>();
|
|
1518
|
-
const active = required.filter(name => name !== missing);
|
|
1519
|
-
const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => active, getAllTools: () => required.map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) } as never;
|
|
1520
|
-
gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(required), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify({ documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } }, "open-web": { tools: required, extensions: Object.fromEntries(required.map(name => [name, "/installed/web.ts"])) } }) });
|
|
1521
|
-
const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
|
|
1522
|
-
assert.match(prompt, /open-web: available/);
|
|
1523
|
-
assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
|
|
1524
|
-
assert.match(prompt, /Availability is not evidence/);
|
|
1485
|
+
test("retired managed SDD child envelopes deny all tools without registering a delegation host", () => {
|
|
1486
|
+
for (const env of [{ GENTLE_PI_RESEARCH_TOOLS: '["read"]' }, { GENTLE_PI_RESEARCH_SELECTION: '{}' }, { GENTLE_PI_RESEARCH_ARTIFACT: '{}' }, { GENTLE_PI_SDD_REMEDIATION_PLAN: '{}' }]) {
|
|
1487
|
+
const hooks = new Map<string, (event: { toolName: string }) => { block: boolean }>();
|
|
1488
|
+
const pi = { on: (name: string, handler: (event: { toolName: string }) => { block: boolean }) => hooks.set(name, handler) } as never;
|
|
1489
|
+
gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", ...env });
|
|
1490
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true);
|
|
1491
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "bash" }).block, true);
|
|
1525
1492
|
}
|
|
1526
1493
|
});
|
|
1527
1494
|
|
|
1528
|
-
test("research child rechecks local inventory and blocks gateway calls", async () => {
|
|
1529
|
-
const hooks = new Map<string, (event: any) => any>();
|
|
1530
|
-
const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => ["read", "mcp"], getAllTools: () => [{ name: "read" }, { name: "mcp" }] } as never;
|
|
1531
|
-
gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: '["read","fetch_content"]' });
|
|
1532
|
-
assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
|
|
1533
|
-
assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
|
|
1534
|
-
assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
|
|
1535
|
-
assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing path context cannot authorize a read");
|
|
1536
|
-
});
|
|
1537
|
-
|
|
1538
1495
|
async function shutdownAndRestoreNativeSpawn(
|
|
1539
1496
|
childProcess: typeof import("node:child_process"),
|
|
1540
1497
|
originalSpawn: typeof import("node:child_process").spawn,
|
|
@@ -1605,6 +1562,26 @@ async function childSessionChangeEvidence(root: string, relPath: string, toolCal
|
|
|
1605
1562
|
return result.details.gentleSessionChange;
|
|
1606
1563
|
}
|
|
1607
1564
|
|
|
1565
|
+
async function childSessionEditEvidence(root: string, relPath: string, toolCallId: string, before: string, after: string): Promise<SessionChangeEvidence> {
|
|
1566
|
+
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
|
1567
|
+
const childPi = {
|
|
1568
|
+
on: (key: string, fn: (event: unknown, ctx: unknown) => unknown) => handlers.set(key, fn),
|
|
1569
|
+
appendEntry: () => {},
|
|
1570
|
+
events: { on: () => () => {}, emit: () => {} },
|
|
1571
|
+
} as unknown as ExtensionAPI;
|
|
1572
|
+
const childCtx = { cwd: root, sessionManager: { getSessionId: () => "child-session", getEntries: () => [] } } as unknown as ExtensionContext;
|
|
1573
|
+
installSessionChangeCapture(childPi, { GENTLE_PI_AGENTS_CHILD: "1" }, resolveSessionWorktree);
|
|
1574
|
+
await handlers.get("session_start")?.({}, childCtx);
|
|
1575
|
+
writeFileSync(join(root, relPath), before);
|
|
1576
|
+
const event = { toolCallId, toolName: "edit", input: { path: relPath, oldText: before, newText: after } };
|
|
1577
|
+
await handlers.get("tool_call")?.(event, childCtx);
|
|
1578
|
+
writeFileSync(join(root, relPath), after);
|
|
1579
|
+
const details = { patch: generateUnifiedPatch(relPath, before, after) };
|
|
1580
|
+
const result = (await handlers.get("tool_result")?.({ ...event, isError: false, details }, childCtx)) as { details: { gentleSessionChange: SessionChangeEvidence } } | undefined;
|
|
1581
|
+
assert.ok(result?.details.gentleSessionChange, "successful child edit must carry captured evidence");
|
|
1582
|
+
return result.details.gentleSessionChange;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1608
1585
|
// C1 investigation (odd/tasks/usage-click-and-changes-attribution.md): tried
|
|
1609
1586
|
// to reproduce the live session's "16 subagent_run calls, zero relayed"
|
|
1610
1587
|
// evidence end to end — real repo, real git-backed resolver on both the
|
|
@@ -1756,6 +1733,56 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
1756
1733
|
});
|
|
1757
1734
|
}
|
|
1758
1735
|
|
|
1736
|
+
// gentle-pi#1175 (T2): the subagent mutation receipt carries the model and
|
|
1737
|
+
// effort the runtime resolved for the task, so ASSESS never re-trusts a model
|
|
1738
|
+
// declaration. An inherited (unresolved) model is omitted, never "default".
|
|
1739
|
+
for (const scenario of ["resolved", "inherited"] as const) {
|
|
1740
|
+
test(`subagent mutation receipt records the runtime-resolved writer profile: ${scenario}`, async () => {
|
|
1741
|
+
const h = fakePi();
|
|
1742
|
+
const d = deps();
|
|
1743
|
+
const { ctx } = fakeContext();
|
|
1744
|
+
if (scenario === "inherited") {
|
|
1745
|
+
const fixtureHome = realpathSync(mkdtempSync(join(root, "inherited-writer-")));
|
|
1746
|
+
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
1747
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: inherits the parent model\ntools: [read]\n---\nYou map things.");
|
|
1748
|
+
d.deps.home = fixtureHome;
|
|
1749
|
+
}
|
|
1750
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
1751
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
1752
|
+
d.deps.resolveWorktree = (path, base) => containsResolvedPath(cwd, resolve(base, path)) ? { root: cwd, commonDir: "/fixture/common" } : undefined;
|
|
1753
|
+
const spawn = d.deps.spawn!;
|
|
1754
|
+
d.deps.spawn = (...args) => {
|
|
1755
|
+
const child = spawn(...args);
|
|
1756
|
+
const on = child.on.bind(child);
|
|
1757
|
+
child.on = ((event: string, listener: () => void) => {
|
|
1758
|
+
if (event === "spawn") queueMicrotask(listener);
|
|
1759
|
+
return on(event as "spawn", listener);
|
|
1760
|
+
}) as typeof child.on;
|
|
1761
|
+
return child;
|
|
1762
|
+
};
|
|
1763
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
1764
|
+
await h.fire("session_start", ctx);
|
|
1765
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: cwd }, undefined, undefined, ctx);
|
|
1766
|
+
await tick();
|
|
1767
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path: "file.ts" } });
|
|
1768
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
|
|
1769
|
+
await tick();
|
|
1770
|
+
const receipts = h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT);
|
|
1771
|
+
assert.equal(receipts.length, 1);
|
|
1772
|
+
const data = receipts[0].data as Record<string, unknown>;
|
|
1773
|
+
if (scenario === "resolved") {
|
|
1774
|
+
assert.equal(data.writerModelId, "openai-codex/gpt-5.6-terra");
|
|
1775
|
+
assert.equal(data.writerEffort, "low", "the profile effort override is the runtime-resolved effort");
|
|
1776
|
+
} else {
|
|
1777
|
+
assert.equal(Object.hasOwn(data, "writerModelId"), false, "an inherited model is unknown here and must never be recorded as \"default\"");
|
|
1778
|
+
assert.equal(Object.hasOwn(data, "writerEffort"), false);
|
|
1779
|
+
}
|
|
1780
|
+
assert.deepEqual(pendingReviewMutationProfiles(ctx.sessionManager, cwd), [scenario === "resolved" ? { writerModelId: "openai-codex/gpt-5.6-terra", writerEffort: "low" } : {}]);
|
|
1781
|
+
await h.fire("session_shutdown", ctx);
|
|
1782
|
+
await tick();
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1759
1786
|
// C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
|
|
1760
1787
|
// guard chain's posture is unchanged (spawn-gated registration stays, a
|
|
1761
1788
|
// parent decision) -- these only verify each drop explains itself once, in
|
|
@@ -2060,7 +2087,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2060
2087
|
children[2]!.emit({ type: "agent_settled" });
|
|
2061
2088
|
await permission.result;
|
|
2062
2089
|
|
|
2063
|
-
const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
|
|
2090
|
+
const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), ...childContextExtensionPaths().flatMap((path) => ["--extension", path]), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
|
|
2064
2091
|
assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
|
|
2065
2092
|
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
2066
2093
|
for (const [index, fixture] of ["task", "background", "permission"].entries()) {
|
|
@@ -2153,6 +2180,349 @@ test("native spawn interception restores CommonJS and ESM exports after rejected
|
|
|
2153
2180
|
}
|
|
2154
2181
|
});
|
|
2155
2182
|
|
|
2183
|
+
test("foreign clone tool requires consent before queueing and never enters parent Changes", async (t) => {
|
|
2184
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-target-")));
|
|
2185
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign");
|
|
2186
|
+
const template = join(fixture, "template");
|
|
2187
|
+
mkdirSync(template);
|
|
2188
|
+
try {
|
|
2189
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2190
|
+
const configHome = join(fixture, "config");
|
|
2191
|
+
mkdirSync(configHome);
|
|
2192
|
+
writeFileSync(join(configHome, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles: { pinned: { explore: { model: "openai/foreign-model", thinking: "minimal" } } } }));
|
|
2193
|
+
mkdirSync(join(foreign, ".git", "gentle-ai"));
|
|
2194
|
+
writeFileSync(join(foreign, ".git", "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "pinned" }));
|
|
2195
|
+
const h = fakePi(), runtime = deps();
|
|
2196
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: configHome };
|
|
2197
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2198
|
+
const spawned: string[] = [];
|
|
2199
|
+
const spawnEnvs: Array<Record<string, string | undefined>> = [];
|
|
2200
|
+
const spawn = runtime.deps.spawn!;
|
|
2201
|
+
runtime.deps.spawn = (command, args, options) => { spawned.push(options.cwd); spawnEnvs.push(options.env); return spawn(command, args, options); };
|
|
2202
|
+
const runnerRun = t.mock.method(AgentRunner.prototype, "run");
|
|
2203
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2204
|
+
let resolveConsent!: (answer: boolean) => void;
|
|
2205
|
+
let prompts = 0;
|
|
2206
|
+
const { ctx } = fakeContext(fakeTui, () => { prompts++; return new Promise<boolean>(resolve => { resolveConsent = resolve; }); });
|
|
2207
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2208
|
+
await h.fire("session_start", ctx);
|
|
2209
|
+
const run = h.tools.get("subagent_run")!;
|
|
2210
|
+
assert.match(JSON.stringify(run.parameters.properties.repository_root), /interactive session-scoped consent/i);
|
|
2211
|
+
await assert.rejects(run.execute("wrong-selector", { agent: "explore", task: "Map", workspace_root: foreign, mode: "background" }, undefined, undefined, ctx), /same Git clone/);
|
|
2212
|
+
await assert.rejects(run.execute("both", { agent: "explore", task: "Map", workspace_root: parent, repository_root: foreign, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
|
|
2213
|
+
await assert.rejects(run.execute("both-malformed", { agent: "explore", task: "Map", workspace_root: parent, repository_root: 123, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
|
|
2214
|
+
// Blank selectors name no destination, so they must not read as a second target.
|
|
2215
|
+
const blank = await run.execute("blank-roots", { agent: "__absent__", task: "Map", workspace_root: "", repository_root: "", mode: "background" }, undefined, undefined, ctx);
|
|
2216
|
+
assert.match(JSON.stringify(blank), /no subagent named/, "blank selectors pass the exclusivity guard and reach agent lookup");
|
|
2217
|
+
assert.deepEqual(spawned, [], "a blank selector must not spawn a child");
|
|
2218
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), [], "a blank selector must not register a worktree");
|
|
2219
|
+
const pending = run.execute("foreign", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2220
|
+
await tick();
|
|
2221
|
+
assert.equal(prompts, 1);
|
|
2222
|
+
assert.deepEqual(spawned, []);
|
|
2223
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2224
|
+
resolveConsent(true);
|
|
2225
|
+
const result = await pending;
|
|
2226
|
+
await tick();
|
|
2227
|
+
assert.deepEqual(spawned, [foreign]);
|
|
2228
|
+
assert.equal((runnerRun.mock.calls[0]?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, undefined, "foreign child must not receive parent review permission channel");
|
|
2229
|
+
assert.equal(runtime.spawned[0]?.[runtime.spawned[0]!.indexOf("--model") + 1], "openai/foreign-model:minimal");
|
|
2230
|
+
assert.equal((result.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2231
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2232
|
+
const reused = await run.execute("reuse", { agent: "explore", task: "Map again", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2233
|
+
assert.equal(prompts, 1);
|
|
2234
|
+
assert.equal((reused.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2235
|
+
// Git's ambient routing must not turn an explicit foreign destination into the parent's repository.
|
|
2236
|
+
const oldGitDir = process.env.GIT_DIR;
|
|
2237
|
+
const oldGitWorkTree = process.env.GIT_WORK_TREE;
|
|
2238
|
+
try {
|
|
2239
|
+
process.env.GIT_DIR = join(parent, ".git");
|
|
2240
|
+
process.env.GIT_WORK_TREE = parent;
|
|
2241
|
+
runtime.deps.env!.GIT_DIR = join(parent, ".git");
|
|
2242
|
+
runtime.deps.env!.GIT_WORK_TREE = parent;
|
|
2243
|
+
const injected = await run.execute("injected-git", { agent: "explore", task: "Map with ambient Git routing", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2244
|
+
assert.equal((injected.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2245
|
+
assert.equal(prompts, 1);
|
|
2246
|
+
// Drain the active child so the queued injected launch reaches the actual OS spawn.
|
|
2247
|
+
runtime.children[0].emit({ type: "agent_end", messages: [] });
|
|
2248
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2249
|
+
runtime.children[0].exit(0);
|
|
2250
|
+
await tick();
|
|
2251
|
+
assert.equal(spawned.at(-1), foreign);
|
|
2252
|
+
assert.equal(spawnEnvs.at(-1)?.GIT_DIR, undefined);
|
|
2253
|
+
assert.equal(spawnEnvs.at(-1)?.GIT_WORK_TREE, undefined);
|
|
2254
|
+
} finally {
|
|
2255
|
+
delete runtime.deps.env!.GIT_DIR;
|
|
2256
|
+
delete runtime.deps.env!.GIT_WORK_TREE;
|
|
2257
|
+
if (oldGitDir === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = oldGitDir;
|
|
2258
|
+
if (oldGitWorkTree === undefined) delete process.env.GIT_WORK_TREE; else process.env.GIT_WORK_TREE = oldGitWorkTree;
|
|
2259
|
+
}
|
|
2260
|
+
const queued = await run.execute("queued", { agent: "explore", task: "Third map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2261
|
+
assert.equal((queued.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2262
|
+
assert.equal(runtime.children.length, 3, "later launches wait in the runner queue");
|
|
2263
|
+
await run.execute("same-clone", { agent: "explore", task: "Map parent", workspace_root: parent, mode: "background" }, undefined, undefined, ctx);
|
|
2264
|
+
assert.equal(typeof (runnerRun.mock.calls.at(-1)?.arguments[0] as { authorizeParentStandingReviewPermission?: unknown }).authorizeParentStandingReviewPermission, "function", "same-clone child retains parent review permission channel");
|
|
2265
|
+
const { ctx: successor } = fakeContext();
|
|
2266
|
+
successor.sessionManager.getCwd = () => parent;
|
|
2267
|
+
await h.fire("session_start", successor);
|
|
2268
|
+
assert.notEqual(successor.sessionManager, ctx.sessionManager);
|
|
2269
|
+
assert.equal(successor.sessionManager.getSessionId(), ctx.sessionManager.getSessionId(), "replacement retains the same session ID");
|
|
2270
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
2271
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2272
|
+
runtime.children[0].exit(0);
|
|
2273
|
+
await tick();
|
|
2274
|
+
assert.equal(runtime.children.length, 3, "stale queued foreign task must fail before OS spawn");
|
|
2275
|
+
await h.fire("session_shutdown", successor);
|
|
2276
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
test("foreign child Changes require successful target-bound tool evidence, never model claims or sibling writes", async () => {
|
|
2280
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-changes-")));
|
|
2281
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), sibling = join(fixture, "sibling"), template = join(fixture, "template");
|
|
2282
|
+
mkdirSync(template);
|
|
2283
|
+
try {
|
|
2284
|
+
for (const path of [parent, foreign, sibling]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2285
|
+
const h = fakePi(), runtime = deps();
|
|
2286
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2287
|
+
const spawn = runtime.deps.spawn!;
|
|
2288
|
+
let proveSpawn: (() => void) | undefined;
|
|
2289
|
+
runtime.deps.spawn = (...args) => {
|
|
2290
|
+
const child = spawn(...args);
|
|
2291
|
+
const on = child.on.bind(child);
|
|
2292
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2293
|
+
if (event === "spawn") proveSpawn = listener;
|
|
2294
|
+
return on(event as "spawn", listener);
|
|
2295
|
+
}) as typeof child.on;
|
|
2296
|
+
return child;
|
|
2297
|
+
};
|
|
2298
|
+
installSessionChangeCapture(h.pi, {}, resolveSessionWorktree);
|
|
2299
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2300
|
+
const { ctx } = fakeContext();
|
|
2301
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2302
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
2303
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
2304
|
+
await h.fire("session_start", ctx);
|
|
2305
|
+
const launched = await h.tools.get("subagent_run")!.execute("foreign-evidence", { agent: "explore", task: "Write", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2306
|
+
await tick();
|
|
2307
|
+
const taskId = (launched.details.gentleAgents as { taskId: string }).taskId;
|
|
2308
|
+
const child = runtime.children[0];
|
|
2309
|
+
const send = async (id: string, root: string, path: string, error = false, forgedRoot?: string) => {
|
|
2310
|
+
const captured = await childSessionChangeEvidence(root, path, id, "agent output\n");
|
|
2311
|
+
const evidence = forgedRoot ? { ...captured, root: forgedRoot } : captured;
|
|
2312
|
+
child.emit({ type: "tool_execution_start", toolCallId: id, toolName: "write", args: { path } });
|
|
2313
|
+
child.emit({ type: "tool_execution_end", toolCallId: id, isError: error, result: { content: [], details: { gentleSessionChange: evidence } } });
|
|
2314
|
+
await tick();
|
|
2315
|
+
};
|
|
2316
|
+
child.emit({ type: "message_update", text: `I edited ${join(foreign, "claimed.md")}` });
|
|
2317
|
+
await send("unspawned", foreign, "unspawned.md");
|
|
2318
|
+
assert.equal(h.entries.some(entry => entry.customType === "gentle-pi.session-change/v1"), false, "unproven spawn cannot attribute a mutation");
|
|
2319
|
+
assert.ok(proveSpawn);
|
|
2320
|
+
proveSpawn();
|
|
2321
|
+
await tick();
|
|
2322
|
+
await send("sibling", sibling, "sibling.md");
|
|
2323
|
+
await send("forged", foreign, "forged.md", false, parent);
|
|
2324
|
+
await send("failed", foreign, "failed.md", true);
|
|
2325
|
+
assert.equal(h.entries.some(entry => entry.customType === "gentle-pi.session-change/v1"), false);
|
|
2326
|
+
await send("accepted", foreign, "accepted.md");
|
|
2327
|
+
const spacedEvidence = await childSessionChangeEvidence(foreign, "space name.md", "unicode-space", "agent output\n");
|
|
2328
|
+
child.emit({ type: "tool_execution_start", toolCallId: "unicode-space", toolName: "write", args: { path: "space\u00a0name.md" } });
|
|
2329
|
+
child.emit({ type: "tool_execution_end", toolCallId: "unicode-space", isError: false, result: { content: [], details: { gentleSessionChange: spacedEvidence } } });
|
|
2330
|
+
await tick();
|
|
2331
|
+
const editEvidence = await childSessionEditEvidence(foreign, "edited.md", "edit-accepted", "original\n", "changed\n");
|
|
2332
|
+
child.emit({ type: "tool_execution_start", toolCallId: "edit-accepted", toolName: "edit", args: { path: "edited.md" } });
|
|
2333
|
+
child.emit({ type: "tool_execution_end", toolCallId: "edit-accepted", isError: false, result: { content: [], details: { gentleSessionChange: editEvidence } } });
|
|
2334
|
+
await tick();
|
|
2335
|
+
const changes = new SessionChanges(ctx.sessionManager.getSessionId()!, h.entries);
|
|
2336
|
+
assert.deepEqual(changes.worktrees.map(tree => tree.root), [foreign]);
|
|
2337
|
+
assert.deepEqual(changes.model.files.map(file => file.path).sort(), ["accepted.md", "edited.md", "space name.md"]);
|
|
2338
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2339
|
+
assert.equal(h.events.some(event => event.name === "gentle-pi:child-session-change"), false);
|
|
2340
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3);
|
|
2341
|
+
assert.equal(changes.worktrees[0]?.model.files.find(file => file.path === "accepted.md")?.status, "added");
|
|
2342
|
+
assert.equal(changes.worktrees[0]?.model.files.find(file => file.path === "edited.md")?.status, "modified");
|
|
2343
|
+
mkdirSync(join(foreign, "nested"));
|
|
2344
|
+
const rebound = await childSessionChangeEvidence(foreign, "nested/rebound.md", "rebound", "not attributed\n");
|
|
2345
|
+
child.emit({ type: "tool_execution_start", toolCallId: "rebound", toolName: "write", args: { path: "nested/rebound.md" } });
|
|
2346
|
+
execFileSync("git", ["init", "--quiet", `--template=${template}`, join(foreign, "nested")]);
|
|
2347
|
+
child.emit({ type: "tool_execution_end", toolCallId: "rebound", isError: false, result: { content: [], details: { gentleSessionChange: rebound } } });
|
|
2348
|
+
await tick();
|
|
2349
|
+
assert.equal(h.entries.filter(entry => entry.customType === "gentle-pi.session-change/v1").length, 3, "new nested Git identity must not inherit foreign target attribution");
|
|
2350
|
+
const stale = await childSessionChangeEvidence(foreign, "stale.md", "stale", "not attributed\n");
|
|
2351
|
+
child.emit({ type: "tool_execution_start", toolCallId: "stale", toolName: "write", args: { path: "stale.md" } });
|
|
2352
|
+
const { ctx: successor } = fakeContext();
|
|
2353
|
+
successor.sessionManager.getCwd = () => parent;
|
|
2354
|
+
await h.fire("session_start", successor);
|
|
2355
|
+
child.emit({ type: "tool_execution_end", toolCallId: "stale", isError: false, result: { content: [], details: { gentleSessionChange: stale } } });
|
|
2356
|
+
await tick();
|
|
2357
|
+
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
|
+
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 0);
|
|
2359
|
+
await h.fire("session_shutdown", successor);
|
|
2360
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
test("foreign task mode waits for the child and continuation reuses its live grant", async () => {
|
|
2364
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-task-")));
|
|
2365
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2366
|
+
mkdirSync(template);
|
|
2367
|
+
try {
|
|
2368
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2369
|
+
const h = fakePi(), runtime = deps();
|
|
2370
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2371
|
+
const spawn = runtime.deps.spawn!;
|
|
2372
|
+
runtime.deps.spawn = (...args) => {
|
|
2373
|
+
const child = spawn(...args);
|
|
2374
|
+
const on = child.on.bind(child);
|
|
2375
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2376
|
+
if (event === "spawn") queueMicrotask(listener);
|
|
2377
|
+
return on(event as "spawn", listener);
|
|
2378
|
+
}) as typeof child.on;
|
|
2379
|
+
return child;
|
|
2380
|
+
};
|
|
2381
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2382
|
+
let prompts = 0;
|
|
2383
|
+
const { ctx } = fakeContext(fakeTui, async () => { prompts++; return true; });
|
|
2384
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2385
|
+
await h.fire("session_start", ctx);
|
|
2386
|
+
let finished = false;
|
|
2387
|
+
const pending = h.tools.get("subagent_run")!.execute("task", { agent: "explore", task: "Map", repository_root: foreign, mode: "task" }, undefined, undefined, ctx).then(result => { finished = true; return result; });
|
|
2388
|
+
await tick();
|
|
2389
|
+
assert.equal(finished, false, "task mode waits for settlement");
|
|
2390
|
+
assert.equal(runtime.children.length, 1);
|
|
2391
|
+
assert.equal(prompts, 1);
|
|
2392
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
2393
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2394
|
+
runtime.children[0].exit(0);
|
|
2395
|
+
const first = await pending;
|
|
2396
|
+
assert.equal((first.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2397
|
+
const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
|
|
2398
|
+
const continued = h.tools.get("subagent_continue")!.execute("follow-up", { task_id: taskId, prompt: "Follow up", mode: "task" }, undefined, undefined, ctx);
|
|
2399
|
+
await tick();
|
|
2400
|
+
assert.equal(runtime.children.length, 2);
|
|
2401
|
+
assert.equal(prompts, 1, "continuation cannot prompt for a second grant");
|
|
2402
|
+
runtime.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "continued" }] }] });
|
|
2403
|
+
runtime.children[1].emit({ type: "agent_settled" });
|
|
2404
|
+
runtime.children[1].exit(0);
|
|
2405
|
+
assert.equal(((await continued).details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2406
|
+
assert.equal(runtime.spawned.length, 2);
|
|
2407
|
+
await h.fire("session_shutdown", ctx);
|
|
2408
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2409
|
+
});
|
|
2410
|
+
|
|
2411
|
+
test("aborting during foreign consent cannot grant or queue a background child", async () => {
|
|
2412
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-abort-")));
|
|
2413
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2414
|
+
mkdirSync(template);
|
|
2415
|
+
try {
|
|
2416
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2417
|
+
const h = fakePi(), runtime = deps();
|
|
2418
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2419
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2420
|
+
let confirm!: (answer: boolean) => void;
|
|
2421
|
+
const { ctx } = fakeContext(fakeTui, () => new Promise(resolve => { confirm = resolve; }));
|
|
2422
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2423
|
+
await h.fire("session_start", ctx);
|
|
2424
|
+
const abort = new AbortController();
|
|
2425
|
+
const pending = h.tools.get("subagent_run")!.execute("abort", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, abort.signal, undefined, ctx);
|
|
2426
|
+
await tick();
|
|
2427
|
+
abort.abort("interrupted");
|
|
2428
|
+
confirm(true);
|
|
2429
|
+
await assert.rejects(pending, /abort|cancel/i);
|
|
2430
|
+
assert.equal(runtime.children.length, 0);
|
|
2431
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2432
|
+
await h.fire("session_shutdown", ctx);
|
|
2433
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
test("an interactive non-Git umbrella can target an independent repository without registering it", async () => {
|
|
2437
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-umbrella-")));
|
|
2438
|
+
const umbrella = join(fixture, "umbrella"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2439
|
+
mkdirSync(umbrella); mkdirSync(template);
|
|
2440
|
+
try {
|
|
2441
|
+
execFileSync("git", ["init", "--quiet", `--template=${template}`, foreign]);
|
|
2442
|
+
const h = fakePi(), runtime = deps();
|
|
2443
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2444
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2445
|
+
const { ctx } = fakeContext();
|
|
2446
|
+
ctx.sessionManager.getCwd = () => umbrella;
|
|
2447
|
+
await h.fire("session_start", ctx);
|
|
2448
|
+
const run = h.tools.get("subagent_run")!;
|
|
2449
|
+
const launched = await run.execute("umbrella", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2450
|
+
assert.equal((launched.details.gentleAgents as { cwd: string }).cwd, foreign);
|
|
2451
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2452
|
+
await h.fire("session_shutdown", ctx);
|
|
2453
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
test("foreign selector rejects RPC and retired SDD selections before consent", async () => {
|
|
2457
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-callers-")));
|
|
2458
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2459
|
+
mkdirSync(template);
|
|
2460
|
+
try {
|
|
2461
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2462
|
+
const h = fakePi(), runtime = deps();
|
|
2463
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2464
|
+
const agentHome = join(fixture, "agent-home");
|
|
2465
|
+
mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2466
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
|
|
2467
|
+
writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nApply");
|
|
2468
|
+
runtime.deps.home = agentHome;
|
|
2469
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2470
|
+
const { ctx, dialogs } = fakeContext();
|
|
2471
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2472
|
+
await h.fire("session_start", ctx);
|
|
2473
|
+
ctx.mode = "rpc";
|
|
2474
|
+
const run = h.tools.get("subagent_run")!;
|
|
2475
|
+
const base = { agent: "explore", task: "Map", repository_root: foreign, mode: "background" };
|
|
2476
|
+
await assert.rejects(run.execute("rpc", base, undefined, undefined, ctx), /interactive parent session/);
|
|
2477
|
+
ctx.mode = "print";
|
|
2478
|
+
await assert.rejects(run.execute("print", { ...base, mode: "task" }, undefined, undefined, ctx), /interactive parent session/);
|
|
2479
|
+
ctx.mode = "tui";
|
|
2480
|
+
assert.match((await run.execute("remediate", { ...base, remediation: {} }, undefined, undefined, ctx)).content[0].text, /retired SDD/);
|
|
2481
|
+
assert.match((await run.execute("foreign-sdd", { ...base, agent: "sdd-apply", sdd_change: { changeName: "alpha", workspaceRoot: foreign, phase: "apply" } }, undefined, undefined, ctx)).content[0].text, /retired SDD/);
|
|
2482
|
+
assert.equal(existsSync(join(agentHome, ".pi", "agent", "sessions")), false, "foreign explicit selection rejection must precede child session directory creation");
|
|
2483
|
+
const childHost = fakePi();
|
|
2484
|
+
gentleAgents(childHost.pi, { GENTLE_PI_AGENTS_CHILD: "1" }, runtime.deps);
|
|
2485
|
+
assert.equal(childHost.tools.has("subagent_run"), false, "child-originated foreign launches have no delegation tool");
|
|
2486
|
+
assert.equal(dialogs.length, 0);
|
|
2487
|
+
assert.equal(runtime.children.length, 0);
|
|
2488
|
+
await h.fire("session_shutdown", ctx);
|
|
2489
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
test("foreign clone rejects aliases, absent UI, decline and changed session before any child starts", async () => {
|
|
2493
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-denials-")));
|
|
2494
|
+
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2495
|
+
mkdirSync(template);
|
|
2496
|
+
try {
|
|
2497
|
+
for (const path of [parent, foreign]) execFileSync("git", ["init", "--quiet", `--template=${template}`, path]);
|
|
2498
|
+
const h = fakePi(), runtime = deps();
|
|
2499
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2500
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2501
|
+
let decision!: (answer: boolean) => void;
|
|
2502
|
+
const { ctx, dialogs } = fakeContext(fakeTui, () => new Promise(resolve => { decision = resolve; }));
|
|
2503
|
+
ctx.sessionManager.getCwd = () => parent;
|
|
2504
|
+
let id = "original";
|
|
2505
|
+
ctx.sessionManager.getSessionId = () => id;
|
|
2506
|
+
await h.fire("session_start", ctx);
|
|
2507
|
+
const run = h.tools.get("subagent_run")!;
|
|
2508
|
+
const args = (path: string) => ({ agent: "explore", task: "Map", repository_root: path, mode: "background" });
|
|
2509
|
+
await assert.rejects(run.execute("alias", args(`${foreign}/.`), undefined, undefined, ctx), /canonical independent Git repository/);
|
|
2510
|
+
ctx.hasUI = false;
|
|
2511
|
+
await assert.rejects(run.execute("no-ui", args(foreign), undefined, undefined, ctx), /interactive/);
|
|
2512
|
+
ctx.hasUI = true;
|
|
2513
|
+
const declined = run.execute("decline", args(foreign), undefined, undefined, ctx);
|
|
2514
|
+
await tick(); decision(false);
|
|
2515
|
+
await assert.rejects(declined, /interactive/);
|
|
2516
|
+
const drift = run.execute("drift", args(foreign), undefined, undefined, ctx);
|
|
2517
|
+
await tick(); id = "replacement"; decision(true);
|
|
2518
|
+
await assert.rejects(drift, /identity changed/);
|
|
2519
|
+
assert.equal(runtime.spawned.length, 0);
|
|
2520
|
+
assert.equal(dialogs.filter(dialog => dialog.startsWith("confirm:")).length, 2);
|
|
2521
|
+
assert.deepEqual(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY), []);
|
|
2522
|
+
await h.fire("session_shutdown", ctx);
|
|
2523
|
+
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2156
2526
|
test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
|
|
2157
2527
|
const h = fakePi();
|
|
2158
2528
|
const runtime = deps();
|
|
@@ -2204,39 +2574,173 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
|
|
|
2204
2574
|
await tick();
|
|
2205
2575
|
});
|
|
2206
2576
|
|
|
2207
|
-
test("SDD
|
|
2577
|
+
test("retired SDD agent names and selection are rejected before dispatch", async () => {
|
|
2208
2578
|
const h = fakePi();
|
|
2209
2579
|
const runtime = deps();
|
|
2210
2580
|
const fixtureHome = join(root, "sdd-selection-home");
|
|
2211
2581
|
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2212
2582
|
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nSDD apply executor");
|
|
2583
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
|
|
2213
2584
|
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome });
|
|
2214
2585
|
const { ctx } = fakeContext();
|
|
2215
2586
|
await h.fire("session_start", ctx);
|
|
2216
|
-
const
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
const
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
runtime.
|
|
2587
|
+
const tool = h.tools.get("subagent_run")!;
|
|
2588
|
+
for (const agent of ["sdd-apply", "sdd-verify", "sdd-archive", "sdd-research", "sdd-remediate", "sdd-custom"]) {
|
|
2589
|
+
const rejected = await tool.execute(agent, { agent, task: "Retired phase", mode: "background" }, undefined, undefined, ctx);
|
|
2590
|
+
assert.match(rejected.content[0].text, /retired SDD/i, agent);
|
|
2591
|
+
}
|
|
2592
|
+
for (const selector of ["sdd_change", "remediation", "research_selection"] as const) {
|
|
2593
|
+
const rejected = await tool.execute(selector, { agent: "explore", task: "Map", [selector]: {} }, undefined, undefined, ctx);
|
|
2594
|
+
assert.match(rejected.content[0].text, /retired SDD/i, selector);
|
|
2595
|
+
}
|
|
2596
|
+
assert.equal(runtime.spawned.length, 0);
|
|
2597
|
+
const ordinary = await tool.execute("ordinary", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
2226
2598
|
await tick();
|
|
2227
|
-
|
|
2228
|
-
|
|
2599
|
+
assert.equal(runtime.spawned.length, 1, "ordinary named agents remain dispatchable");
|
|
2600
|
+
const id = (ordinary.details.gentleAgents as { taskId: string }).taskId;
|
|
2601
|
+
for (const selector of ["sdd_change", "remediation", "research_selection"] as const) {
|
|
2602
|
+
const rejected = await h.tools.get("subagent_continue")!.execute(selector, { task_id: id, prompt: "Follow up", [selector]: {} }, undefined, undefined, ctx);
|
|
2603
|
+
assert.match(rejected.content[0].text, /retired SDD/i, selector);
|
|
2604
|
+
}
|
|
2229
2605
|
assert.equal(runtime.spawned.length, 1);
|
|
2230
|
-
await h.tools.get("subagent_continue")!.execute("continue", {
|
|
2231
|
-
task_id: taskId, prompt: "Apply beta", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
|
|
2232
|
-
sdd_change: { changeName: "beta", workspaceRoot: cwd, phase: "apply" },
|
|
2233
|
-
}, undefined, undefined, ctx);
|
|
2234
|
-
await tick();
|
|
2235
|
-
const second = runtime.spawned[1]!;
|
|
2236
|
-
assert.deepEqual(JSON.parse(second[second.indexOf("--gentle-sdd-change") + 1]!), { changeName: "beta", workspaceRoot: cwd, phase: "apply" });
|
|
2237
2606
|
await h.fire("session_shutdown", ctx);
|
|
2238
2607
|
});
|
|
2239
2608
|
|
|
2609
|
+
for (const drift of ["loss", "common-dir", "root"] as const) {
|
|
2610
|
+
test(`established writer authority rejects metadata ${drift} before preparation or queue`, async () => {
|
|
2611
|
+
const fixture = realpathSync(mkdtempSync(join(root, "established-writer-")));
|
|
2612
|
+
const project = join(fixture, "project");
|
|
2613
|
+
const cwd = join(project, "nested");
|
|
2614
|
+
const home = join(fixture, "home");
|
|
2615
|
+
mkdirSync(cwd, { recursive: true });
|
|
2616
|
+
const definitions = join(home, ".pi", "agent", "agents");
|
|
2617
|
+
mkdirSync(definitions, { recursive: true });
|
|
2618
|
+
writeFileSync(join(definitions, "worker.md"), "---\ndescription: fixture\nmodel: offline/good\n---\nFixture");
|
|
2619
|
+
execFileSync("git", ["init", "--quiet", project]);
|
|
2620
|
+
const h = fakePi();
|
|
2621
|
+
const runtime = deps();
|
|
2622
|
+
Object.assign(runtime.deps, { home, resolveWorktree: resolveSessionWorktree });
|
|
2623
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2624
|
+
const { ctx } = fakeContext();
|
|
2625
|
+
Object.assign(ctx, { cwd, modelRegistry: { find: () => ({ provider: "offline", id: "good" }) } });
|
|
2626
|
+
ctx.sessionManager.getCwd = () => cwd;
|
|
2627
|
+
await h.fire("session_start", ctx);
|
|
2628
|
+
let statusCalls = 0;
|
|
2629
|
+
const unbind = bindSessionRepositoryPreparation(ctx.sessionManager, cwd, async () => { statusCalls++; return true; }, () => true);
|
|
2630
|
+
try {
|
|
2631
|
+
if (drift === "root") execFileSync("git", ["init", "--quiet", cwd]);
|
|
2632
|
+
else {
|
|
2633
|
+
renameSync(join(project, ".git"), join(project, "saved-git"));
|
|
2634
|
+
if (drift === "common-dir") {
|
|
2635
|
+
const alternate = join(fixture, "alternate");
|
|
2636
|
+
mkdirSync(alternate);
|
|
2637
|
+
execFileSync("git", ["init", "--quiet", alternate]);
|
|
2638
|
+
writeFileSync(join(project, ".git"), `gitdir: ${join(alternate, ".git")}\n`);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
await assert.rejects(h.tools.get("subagent_run")!.execute("lost-authority", { agent: "worker", task: "Implement\n## Allowed edit surfaces\nsrc/app.ts", mode: "background" }, undefined, undefined, ctx));
|
|
2642
|
+
assert.equal(statusCalls, 0, "no bootstrap-capable STATUS after established identity drift");
|
|
2643
|
+
assert.equal(runtime.children.length, 0, "no writer queued/spawned after established identity drift");
|
|
2644
|
+
} finally { unbind(); await h.fire("session_shutdown", ctx); }
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
for (const scenario of ["implicit-worker", "explicit-worker", "implicit-gentle-ai-worker", "explicit-gentle-ai-worker", "scope", "task", "mode", "model", "profile-model", "profile-valid", "foreign", "nested", "repository", "print", "cancelled", "missing", "off", "shutdown", "replacement", "changed-id", "during-cancel", "docs", "read-only", "review", "jd"] as const) {
|
|
2649
|
+
test(`bounded writer executor admission before bootstrap: ${scenario}`, async t => {
|
|
2650
|
+
const fixture = realpathSync(mkdtempSync(join(root, "writer-admission-")));
|
|
2651
|
+
const project = join(fixture, "project");
|
|
2652
|
+
const fixtureHome = join(fixture, "home");
|
|
2653
|
+
const definitions = join(fixtureHome, ".pi", "agent", "agents");
|
|
2654
|
+
mkdirSync(definitions, { recursive: true });
|
|
2655
|
+
mkdirSync(project);
|
|
2656
|
+
mkdirSync(join(project, "nested"));
|
|
2657
|
+
for (const role of ["worker", "gentle-ai-worker", "explore", "reviewer", "jd-fix-agent"]) writeFileSync(join(definitions, `${role}.md`), `---\ndescription: fixture\nmodel: offline/good\ntools: [read]\n---\nFixture`);
|
|
2658
|
+
const h = fakePi();
|
|
2659
|
+
const runtime = deps();
|
|
2660
|
+
runtime.deps.home = fixtureHome;
|
|
2661
|
+
runtime.deps.resolveWorktree = resolveSessionWorktree;
|
|
2662
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2663
|
+
const { ctx } = fakeContext();
|
|
2664
|
+
Object.assign(ctx, { cwd: project, modelRegistry: { find: (_provider: string, model: string) => scenario === "model" || model === "bad" ? undefined : { provider: "offline", id: model } } });
|
|
2665
|
+
ctx.sessionManager.getCwd = () => project;
|
|
2666
|
+
ctx.sessionManager.getEntries = () => h.entries as never;
|
|
2667
|
+
await h.fire("session_start", ctx);
|
|
2668
|
+
if (scenario === "profile-model" || scenario === "profile-valid") {
|
|
2669
|
+
mkdirSync(join(project, ".pi", "gentle-ai"), { recursive: true });
|
|
2670
|
+
writeFileSync(join(project, ".pi", "gentle-ai", "profile.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "invalid" }));
|
|
2671
|
+
const config = runtime.deps.env!.GENTLE_PI_CONFIG_HOME = join(fixture, "config");
|
|
2672
|
+
mkdirSync(config, { recursive: true });
|
|
2673
|
+
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
|
+
}
|
|
2675
|
+
let calls = 0;
|
|
2676
|
+
const abort = new AbortController();
|
|
2677
|
+
const unbind = scenario === "missing" ? () => {} : bindSessionRepositoryPreparation(ctx.sessionManager, project, async (_root, current) => {
|
|
2678
|
+
calls++;
|
|
2679
|
+
if (scenario === "shutdown") await h.fire("session_shutdown", ctx);
|
|
2680
|
+
if (scenario === "replacement") { const next = fakeContext(); next.ctx.sessionManager.getCwd = () => project; await h.fire("session_start", next.ctx); }
|
|
2681
|
+
if (scenario === "changed-id") ctx.sessionManager.getSessionId = () => "changed";
|
|
2682
|
+
if (scenario === "during-cancel") abort.abort();
|
|
2683
|
+
if (!current() || scenario === "off" || ["shutdown", "replacement"].includes(scenario)) return false;
|
|
2684
|
+
execFileSync("git", ["init", "--quiet", project], { env: { PATH: process.env.PATH, HOME: fixtureHome, GIT_CONFIG_NOSYSTEM: "1" }, stdio: "pipe" });
|
|
2685
|
+
return true;
|
|
2686
|
+
}, () => true);
|
|
2687
|
+
t.after(() => { unbind(); });
|
|
2688
|
+
if (scenario === "cancelled") abort.abort();
|
|
2689
|
+
if (scenario === "print") Object.assign(ctx, { mode: "print", hasUI: false });
|
|
2690
|
+
const agent = scenario.includes("gentle-ai-worker") ? "gentle-ai-worker" : scenario === "read-only" ? "explore" : scenario === "review" ? "reviewer" : scenario === "jd" ? "jd-fix-agent" : "worker";
|
|
2691
|
+
const task = `Implement source\n## Allowed edit surfaces\n${scenario === "docs" ? "odd/tasks/feature.md" : "src/app.ts"}\n## Return\nReport`;
|
|
2692
|
+
const params = { agent, task: scenario === "scope" ? "No surface" : scenario === "task" ? 42 : task, mode: scenario === "mode" ? "invalid" : "background", ...(scenario.startsWith("explicit") ? { workspace_root: project } : {}), ...(scenario === "nested" ? { workspace_root: join(project, "nested") } : {}), ...(scenario === "foreign" ? { workspace_root: fixtureHome } : {}), ...(scenario === "repository" ? { repository_root: fixtureHome } : {}) };
|
|
2693
|
+
const accepted = scenario.startsWith("implicit-") || scenario.startsWith("explicit-") || scenario === "profile-valid" || ["docs", "read-only", "review", "jd"].includes(scenario);
|
|
2694
|
+
const result = h.tools.get("subagent_run")!.execute("admission", params, abort.signal, undefined, ctx);
|
|
2695
|
+
if (accepted) await result; else await assert.rejects(result);
|
|
2696
|
+
await tick();
|
|
2697
|
+
const prepared = scenario.startsWith("implicit-") || scenario.startsWith("explicit-") || scenario === "profile-valid";
|
|
2698
|
+
if (scenario === "profile-valid") assert.ok(runtime.spawned[0]?.includes("offline/pinned-good"), "repository declaration retains effective profile through bootstrap");
|
|
2699
|
+
assert.equal(calls, prepared || ["off", "shutdown", "replacement", "changed-id", "during-cancel"].includes(scenario) ? 1 : 0);
|
|
2700
|
+
assert.equal(existsSync(join(project, ".git")), prepared);
|
|
2701
|
+
assert.equal(runtime.children.length, accepted ? 1 : 0, "invalid admission never queues/spawns");
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
for (const explicit of [false, true]) {
|
|
2706
|
+
test(`same manager and ID after bootstrap permit ${explicit ? "explicit" : "implicit"} launch registration`, async () => {
|
|
2707
|
+
const h = fakePi();
|
|
2708
|
+
const runtime = deps();
|
|
2709
|
+
let bootstrapped = false;
|
|
2710
|
+
const baseSpawn = runtime.deps.spawn!;
|
|
2711
|
+
runtime.deps.spawn = (command, args, options) => {
|
|
2712
|
+
const child = baseSpawn(command, args, options);
|
|
2713
|
+
const on = child.on.bind(child);
|
|
2714
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2715
|
+
if (event === "spawn") queueMicrotask(listener);
|
|
2716
|
+
else on(event as "exit", listener);
|
|
2717
|
+
return child;
|
|
2718
|
+
}) as typeof child.on;
|
|
2719
|
+
return child;
|
|
2720
|
+
};
|
|
2721
|
+
runtime.deps.resolveWorktree = (path, base) => bootstrapped ? { root: resolve(base, path), commonDir: "/bootstrap/git" } : undefined;
|
|
2722
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2723
|
+
const { ctx } = fakeContext();
|
|
2724
|
+
const manager = ctx.sessionManager;
|
|
2725
|
+
ctx.sessionManager.getEntries = () => h.entries as never;
|
|
2726
|
+
await h.fire("session_start", ctx);
|
|
2727
|
+
assert.deepEqual(h.entries, []);
|
|
2728
|
+
bootstrapped = true;
|
|
2729
|
+
const result = await h.tools.get("subagent_run")!.execute("bootstrap", { agent: "explore", task: "Map", mode: "background", ...(explicit ? { workspace_root: cwd } : {}) }, undefined, undefined, ctx);
|
|
2730
|
+
await tick();
|
|
2731
|
+
assert.equal(runtime.children.length, 1);
|
|
2732
|
+
assert.equal(ctx.sessionManager, manager);
|
|
2733
|
+
assert.equal(ctx.sessionManager.getSessionId(), "s1");
|
|
2734
|
+
assert.equal(h.entries.filter(entry => entry.customType === SESSION_WORKTREE_ENTRY).length, 1);
|
|
2735
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
2736
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2737
|
+
await tick();
|
|
2738
|
+
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
2739
|
+
assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed/);
|
|
2740
|
+
assert.equal(h.entries.some(entry => entry.customType === REVIEW_REMINDER_RECEIPT), false, "spawn registration is not mutation evidence");
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2240
2744
|
test("ordinary non-Git tasks still continue in their original cwd without registering a worktree", async () => {
|
|
2241
2745
|
const h = fakePi();
|
|
2242
2746
|
const runtime = deps();
|
|
@@ -2258,34 +2762,36 @@ test("ordinary non-Git tasks still continue in their original cwd without regist
|
|
|
2258
2762
|
await tick();
|
|
2259
2763
|
});
|
|
2260
2764
|
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2765
|
+
for (const sameId of [false, true]) {
|
|
2766
|
+
test(`delayed child spawn cannot append into a ${sameId ? "same-ID manager" : "new-ID session"} replacement`, async () => {
|
|
2767
|
+
const h = fakePi();
|
|
2768
|
+
const runtime = deps();
|
|
2769
|
+
const spawnEvents: Array<() => void> = [];
|
|
2770
|
+
const baseSpawn = runtime.deps.spawn!;
|
|
2771
|
+
runtime.deps.spawn = (command, args, options) => {
|
|
2772
|
+
const child = baseSpawn(command, args, options);
|
|
2773
|
+
const on = child.on.bind(child);
|
|
2774
|
+
child.on = ((event: string, listener: () => void) => {
|
|
2775
|
+
if (event === "spawn") spawnEvents.push(listener);
|
|
2776
|
+
else on(event as "exit", listener);
|
|
2777
|
+
return child;
|
|
2778
|
+
}) as typeof child.on;
|
|
2272
2779
|
return child;
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
});
|
|
2780
|
+
};
|
|
2781
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2782
|
+
const { ctx } = fakeContext();
|
|
2783
|
+
await h.fire("session_start", ctx);
|
|
2784
|
+
await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", workspace_root: join(root, "old-root"), mode: "background" }, undefined, undefined, ctx);
|
|
2785
|
+
await tick();
|
|
2786
|
+
const next = fakeContext();
|
|
2787
|
+
(next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => sameId ? "s1" : "s2";
|
|
2788
|
+
await h.fire("session_start", next.ctx);
|
|
2789
|
+
spawnEvents[0]();
|
|
2790
|
+
await tick();
|
|
2791
|
+
assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
|
|
2792
|
+
await h.fire("session_shutdown", next.ctx);
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2289
2795
|
|
|
2290
2796
|
test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
|
|
2291
2797
|
assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
|
|
@@ -2513,7 +3019,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
2513
3019
|
harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
|
|
2514
3020
|
harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
|
|
2515
3021
|
await tick();
|
|
2516
|
-
assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.
|
|
3022
|
+
assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.090 · \d+s │$/);
|
|
2517
3023
|
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
|
|
2518
3024
|
harness.children[0].emit({ type: "agent_settled" });
|
|
2519
3025
|
const result = await running;
|
|
@@ -2529,17 +3035,44 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
2529
3035
|
assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
|
|
2530
3036
|
});
|
|
2531
3037
|
|
|
2532
|
-
test("
|
|
2533
|
-
const { pi, tools, fire } = fakePi();
|
|
3038
|
+
test("running subagent_result polls hide only their tool chrome, not the model result or final completion", async () => {
|
|
3039
|
+
const { pi, tools, fire, sent, renderers } = fakePi();
|
|
2534
3040
|
const harness = deps();
|
|
2535
3041
|
gentleAgents(pi, {}, harness.deps);
|
|
2536
|
-
|
|
2537
|
-
// (a host with no .terminal is already a passthrough); this is the host
|
|
2538
|
-
// shape the fullscreen layout actually uses.
|
|
2539
|
-
const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
|
|
2540
|
-
const { ctx, widget } = fakeContext(terminalTui);
|
|
3042
|
+
const { ctx } = fakeContext();
|
|
2541
3043
|
await fire("session_start", ctx);
|
|
2542
|
-
await tools.get("subagent_run")!.execute("
|
|
3044
|
+
const started = await tools.get("subagent_run")!.execute("start", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
|
|
3045
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
3046
|
+
await tick();
|
|
3047
|
+
const resultTool = tools.get("subagent_result")!;
|
|
3048
|
+
for (const expanded of [false, true]) {
|
|
3049
|
+
const poll = await resultTool.execute("poll", { task_id: id }, undefined, undefined, ctx);
|
|
3050
|
+
assert.match(poll.content[0].text, /still running/, "poll remains available to the model");
|
|
3051
|
+
assert.deepEqual(resultTool.renderCall({ task_id: id }, plainTheme).render(80), [], "poll title is hidden");
|
|
3052
|
+
assert.deepEqual(resultTool.renderResult(poll as Parameters<Registered["renderResult"]>[0], { expanded }, plainTheme).render(80), [], "poll body is hidden");
|
|
3053
|
+
}
|
|
3054
|
+
assert.match(tools.get("subagent_status")!.renderCall({ task_id: id }, plainTheme).render(80).join(""), /agent status/, "other tools retain their rendering");
|
|
3055
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All done." }] }] });
|
|
3056
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
3057
|
+
await tick();
|
|
3058
|
+
const finished = await resultTool.execute("finished", { task_id: id }, undefined, undefined, ctx);
|
|
3059
|
+
assert.equal(finished.content[0].text, "All done.");
|
|
3060
|
+
assert.match(resultTool.renderResult(finished as Parameters<Registered["renderResult"]>[0], { expanded: true }, plainTheme).render(80).join("\n"), /agent result.*All done\./s, "completed result remains visible");
|
|
3061
|
+
assert.equal(sent.length, 1);
|
|
3062
|
+
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");
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
|
|
3066
|
+
const { pi, tools, fire } = fakePi();
|
|
3067
|
+
const harness = deps();
|
|
3068
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3069
|
+
// A terminal-bearing host is what makes sidebarPart do anything at all
|
|
3070
|
+
// (a host with no .terminal is already a passthrough); this is the host
|
|
3071
|
+
// shape the fullscreen layout actually uses.
|
|
3072
|
+
const terminalTui = { requestRender() {}, terminal: { columns: 160, rows: 40 } };
|
|
3073
|
+
const { ctx, widget } = fakeContext(terminalTui);
|
|
3074
|
+
await fire("session_start", ctx);
|
|
3075
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Map lib/", label: "map lib modules", mode: "background" }, undefined, undefined, ctx);
|
|
2543
3076
|
await tick();
|
|
2544
3077
|
|
|
2545
3078
|
// The factory only runs (and only then could register a rail part) once
|
|
@@ -2581,9 +3114,9 @@ test("background runs return at once; status, result, send_message, cancel, and
|
|
|
2581
3114
|
assert.equal(sent[0].message.display, true, "completion cards remain visible");
|
|
2582
3115
|
// The completion path must never regress to "followUp": the host drains the
|
|
2583
3116
|
// follow-up queue only when the parent run stops calling tools, which is the
|
|
2584
|
-
// hour-long #867 delay.
|
|
2585
|
-
//
|
|
2586
|
-
assert.deepEqual(sent[0].options, {
|
|
3117
|
+
// hour-long #867 delay. An idle parent stores it and is woken through the
|
|
3118
|
+
// normal prompt lifecycle; an active parent keeps the bounded steer route.
|
|
3119
|
+
assert.deepEqual(sent[0].options, { triggerTurn: false });
|
|
2587
3120
|
assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
|
|
2588
3121
|
const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
|
|
2589
3122
|
assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
|
|
@@ -2642,6 +3175,62 @@ test("completionText names the outcome before the answer", () => {
|
|
|
2642
3175
|
assert.equal(completionText({ ...base, status: "timed_out", error: "stalled for 4 min" }), 'Subagent explore (task t1, "map lib") timed out.\n\nSubagent explore timed_out: stalled for 4 min');
|
|
2643
3176
|
});
|
|
2644
3177
|
|
|
3178
|
+
test("the Agent result card previews the answer or error when collapsed and keeps the full completion text expanded", () => {
|
|
3179
|
+
const { pi, renderers } = fakePi();
|
|
3180
|
+
gentleAgents(pi, {}, deps().deps);
|
|
3181
|
+
const render = renderers.get("gentle-agents.result")!;
|
|
3182
|
+
const base = { id: "t1", agent: "explore", mode: "background", prompt: "p", label: "map lib", cwd: "/r", parentSessionId: "s", status: "completed" as const, createdAt: 1, startedAt: 1, endedAt: 2, model: "m", thinking: undefined, sessionPath: null, error: null, result: "All done.", lastStep: "x", lastActivityAt: 2, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
|
|
3183
|
+
const message = (task: typeof base | (Omit<typeof base, "status" | "error"> & { status: "failed"; error: string })) => ({ customType: "gentle-agents.result", content: completionText(task as Parameters<typeof completionText>[0]), details: { gentleAgents: { agent: task.agent, status: task.status } } });
|
|
3184
|
+
const taggedTheme = { fg: (color: string, text: string) => `<${color}>${text}</${color}>` };
|
|
3185
|
+
|
|
3186
|
+
const done = render(message(base), { expanded: false }, plainTheme).render(80).map(stripAnsi);
|
|
3187
|
+
assert.ok(done.length <= 5, "the collapsed card stays bounded");
|
|
3188
|
+
assert.match(done[0]!, /^╭─ ❀ Agent result · explore ─+ expand ╮$/);
|
|
3189
|
+
assert.match(done[1]!, /^│ All done\. +│$/, "the answer leads the collapsed preview");
|
|
3190
|
+
assert.doesNotMatch(done.join("\n"), /Subagent explore \(task/, "the bookkeeping header stays out of the collapsed preview");
|
|
3191
|
+
assert.match(render(message(base), { expanded: false }, taggedTheme).render(80)[0]!, /^<success>╭/);
|
|
3192
|
+
|
|
3193
|
+
const failed = { ...base, status: "failed" as const, error: "pi exited with code 1", result: null };
|
|
3194
|
+
const failedRows = render(message(failed as never), { expanded: false }, taggedTheme).render(80);
|
|
3195
|
+
assert.match(failedRows[0]!, /^<error>╭/, "a failed result keeps the error frame");
|
|
3196
|
+
assert.match(stripAnsi(failedRows[1]!.replace(/<\/?[a-z]+>/g, "")), /^│ Subagent explore failed: pi exited with code 1 +│$/, "the error leads the collapsed preview");
|
|
3197
|
+
|
|
3198
|
+
const long = { ...base, result: "one\ntwo\n\nthree\nfour\nfive" };
|
|
3199
|
+
const collapsed = render(message(long), { expanded: false }, plainTheme).render(80).map(stripAnsi);
|
|
3200
|
+
assert.deepEqual(collapsed.slice(1, -1).map((row) => row.slice(2).trim().replace(/ │$/, "").trim()), ["one", "two", "three"], "three non-blank answer rows");
|
|
3201
|
+
assert.match(collapsed.at(-1)!, /^╰─+╯$/);
|
|
3202
|
+
const expanded = render(message(long), { expanded: true }, plainTheme).render(80).map(stripAnsi).join("\n");
|
|
3203
|
+
assert.match(expanded, /Subagent explore \(task t1, "map lib"\) finished\./, "expanded keeps the header");
|
|
3204
|
+
for (const line of ["one", "two", "three", "four", "five"]) assert.match(expanded, new RegExp(`│ ${line} +│`));
|
|
3205
|
+
|
|
3206
|
+
const legacy = { customType: "gentle-agents.result", content: [{ type: "text", text: "Older answer without a header." }], details: { gentleAgents: { agent: "explore", status: "completed" } } };
|
|
3207
|
+
assert.match(render(legacy, { expanded: false }, plainTheme).render(80).map(stripAnsi)[1]!, /^│ Older answer without a header\. +│$/, "headerless content falls back to the full text");
|
|
3208
|
+
assert.equal(agentResultPreview('Subagent explore (task t1, "x") finished.\n\n'), 'Subagent explore (task t1, "x") finished.\n\n', "a header without an answer keeps the full text");
|
|
3209
|
+
assert.equal(agentResultPreview("Plain answer.\n\nMore."), "Plain answer.\n\nMore.");
|
|
3210
|
+
|
|
3211
|
+
for (const width of [0, 1, 2, 3, 4, 5, 6, 7, 8, 24]) {
|
|
3212
|
+
const rows = render(message(long), { expanded: false }, plainTheme).render(width);
|
|
3213
|
+
if (width === 0) assert.deepEqual(rows, []);
|
|
3214
|
+
assert.ok(rows.length <= 5, `width ${width} stays within the row budget`);
|
|
3215
|
+
for (const row of rows) assert.ok(visibleWidth(row) <= width, `width ${width} row fits: ${JSON.stringify(row)}`);
|
|
3216
|
+
}
|
|
3217
|
+
});
|
|
3218
|
+
|
|
3219
|
+
test("the Stale agent result card previews its truthful warning when collapsed", () => {
|
|
3220
|
+
const { pi, entryRenderers } = fakePi();
|
|
3221
|
+
gentleAgents(pi, {}, deps().deps);
|
|
3222
|
+
const render = entryRenderers.get("gentle-agents.stale-result")!;
|
|
3223
|
+
const entry = { type: "custom", customType: "gentle-agents.stale-result", data: { taskId: "t9", agent: "explore", label: "map lib", status: "completed", ageSeconds: 120 } };
|
|
3224
|
+
const collapsed = render(entry, { expanded: false }, plainTheme).render(80).map(stripAnsi);
|
|
3225
|
+
assert.ok(collapsed.length > 3 && collapsed.length <= 5, "a bounded multi-row preview");
|
|
3226
|
+
assert.match(collapsed[0]!, /^╭─ ❀ Stale agent result · explore · task t9 ─+ expand ╮$/);
|
|
3227
|
+
assert.match(collapsed.join("\n"), /Subagent explore \(task t9, "map lib"\) completed about 2m ago/);
|
|
3228
|
+
assert.doesNotMatch(collapsed.join("\n"), /All done|Last answer/, "no invented answer");
|
|
3229
|
+
assert.match(render(entry, { expanded: false }, { fg: (color: string, text: string) => `<${color}>${text}</${color}>` }).render(80)[0]!, /^<warning>╭/);
|
|
3230
|
+
assert.match(render(entry, { expanded: true }, plainTheme).render(80).map(stripAnsi).join("\n"), /subagent_status and subagent_result/);
|
|
3231
|
+
assert.deepEqual(render(entry, { expanded: false }, plainTheme).render(0), []);
|
|
3232
|
+
});
|
|
3233
|
+
|
|
2645
3234
|
test("a task-mode child's dialog reaches the host UI and the answer goes back to the child", async () => {
|
|
2646
3235
|
const { pi, tools, fire } = fakePi();
|
|
2647
3236
|
const harness = deps();
|
|
@@ -3288,8 +3877,11 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3288
3877
|
const { ctx, dialogs } = fakeContext();
|
|
3289
3878
|
await h.fire("session_start", ctx);
|
|
3290
3879
|
await eventually(() => callbacks.length === 1, "initial transport callback registration");
|
|
3291
|
-
const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer" }, undefined, undefined, ctx);
|
|
3292
|
-
assert.deepEqual(dialogs, [
|
|
3880
|
+
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
|
+
assert.deepEqual(dialogs, [
|
|
3882
|
+
"select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta",
|
|
3883
|
+
"select:Authorize cross-orchestrator message to alpha?\nReason: because the peer needs an update\nMessage: hello peer:Allow once|Allow for this session|Deny",
|
|
3884
|
+
]);
|
|
3293
3885
|
assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
|
|
3294
3886
|
assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
|
|
3295
3887
|
const original = callbacks[0]!;
|
|
@@ -3301,12 +3893,228 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3301
3893
|
await h.fire("session_shutdown", ctx);
|
|
3302
3894
|
});
|
|
3303
3895
|
|
|
3896
|
+
// Issue #1364: user consent before cross-orchestrator communication
|
|
3897
|
+
test("orchestrator_send_message requires consent on explicit recipient ID and sends nothing when denied", async () => {
|
|
3898
|
+
const h = fakePi();
|
|
3899
|
+
const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
|
|
3900
|
+
const sent: unknown[] = [];
|
|
3901
|
+
const runtime = deps();
|
|
3902
|
+
const registry = { list: async () => [], listActivations: async () => records };
|
|
3903
|
+
runtime.deps.sessionTransport = {
|
|
3904
|
+
createRegistry: async () => registry,
|
|
3905
|
+
createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
|
|
3906
|
+
createClient: () => ({
|
|
3907
|
+
close: () => {},
|
|
3908
|
+
sendNotification: async (recipient: string, message: string) => {
|
|
3909
|
+
sent.push({ recipient, message });
|
|
3910
|
+
return { id: "msg-1", accepted: true };
|
|
3911
|
+
},
|
|
3912
|
+
}),
|
|
3913
|
+
} as never;
|
|
3914
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
3915
|
+
|
|
3916
|
+
const { ctx, dialogs } = fakeContext(
|
|
3917
|
+
undefined,
|
|
3918
|
+
undefined,
|
|
3919
|
+
undefined,
|
|
3920
|
+
undefined,
|
|
3921
|
+
async (_title, options) => options[2] // "Deny"
|
|
3922
|
+
);
|
|
3923
|
+
await h.fire("session_start", ctx);
|
|
3924
|
+
|
|
3925
|
+
const result = await h.tools.get("orchestrator_send_message")!.execute(
|
|
3926
|
+
"send",
|
|
3927
|
+
{ recipient_session_id: "target-peer", message: "sensitive task details", reason: "status check" },
|
|
3928
|
+
undefined,
|
|
3929
|
+
undefined,
|
|
3930
|
+
ctx
|
|
3931
|
+
);
|
|
3932
|
+
|
|
3933
|
+
assert.deepEqual(dialogs, [
|
|
3934
|
+
"select:Authorize cross-orchestrator message to target-peer?\nReason: status check\nMessage: sensitive task details:Allow once|Allow for this session|Deny",
|
|
3935
|
+
]);
|
|
3936
|
+
assert.equal(sent.length, 0, "nothing must be sent when user denies consent");
|
|
3937
|
+
assert.match(result.content[0].text, /denied by user/);
|
|
3938
|
+
assert.equal((result.details as { error: string }).error, "denied");
|
|
3939
|
+
await h.fire("session_shutdown", ctx);
|
|
3940
|
+
});
|
|
3941
|
+
|
|
3942
|
+
test("orchestrator_send_message with Allow for this session skips prompt on subsequent sends to same recipient", async () => {
|
|
3943
|
+
const h = fakePi();
|
|
3944
|
+
const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
|
|
3945
|
+
const sent: unknown[] = [];
|
|
3946
|
+
const runtime = deps();
|
|
3947
|
+
const registry = { list: async () => [], listActivations: async () => records };
|
|
3948
|
+
runtime.deps.sessionTransport = {
|
|
3949
|
+
createRegistry: async () => registry,
|
|
3950
|
+
createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
|
|
3951
|
+
createClient: () => ({
|
|
3952
|
+
close: () => {},
|
|
3953
|
+
sendNotification: async (recipient: string, message: string) => {
|
|
3954
|
+
sent.push({ recipient, message });
|
|
3955
|
+
return { id: "msg-1", accepted: true };
|
|
3956
|
+
},
|
|
3957
|
+
}),
|
|
3958
|
+
} as never;
|
|
3959
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
3960
|
+
|
|
3961
|
+
const { ctx, dialogs } = fakeContext(
|
|
3962
|
+
undefined,
|
|
3963
|
+
undefined,
|
|
3964
|
+
undefined,
|
|
3965
|
+
undefined,
|
|
3966
|
+
async (_title, options) => options[1] // "Allow for this session"
|
|
3967
|
+
);
|
|
3968
|
+
await h.fire("session_start", ctx);
|
|
3969
|
+
|
|
3970
|
+
// First send
|
|
3971
|
+
const res1 = await h.tools.get("orchestrator_send_message")!.execute(
|
|
3972
|
+
"send-1",
|
|
3973
|
+
{ recipient_session_id: "target-peer", message: "msg 1", reason: "because the first update is needed" },
|
|
3974
|
+
undefined,
|
|
3975
|
+
undefined,
|
|
3976
|
+
ctx
|
|
3977
|
+
);
|
|
3978
|
+
assert.match(res1.content[0].text, /accepted for delivery/);
|
|
3979
|
+
assert.equal(dialogs.length, 1);
|
|
3980
|
+
assert.equal(sent.length, 1);
|
|
3981
|
+
|
|
3982
|
+
// Second send to same recipient in same session must NOT prompt again
|
|
3983
|
+
const res2 = await h.tools.get("orchestrator_send_message")!.execute(
|
|
3984
|
+
"send-2",
|
|
3985
|
+
{ recipient_session_id: "target-peer", message: "msg 2", reason: "because the next update is needed" },
|
|
3986
|
+
undefined,
|
|
3987
|
+
undefined,
|
|
3988
|
+
ctx
|
|
3989
|
+
);
|
|
3990
|
+
assert.match(res2.content[0].text, /accepted for delivery/);
|
|
3991
|
+
assert.equal(dialogs.length, 1, "must not open a second consent dialog for the same session-granted recipient");
|
|
3992
|
+
assert.equal(sent.length, 2);
|
|
3993
|
+
await h.fire("session_shutdown", ctx);
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
test("orchestrator_send_message fails closed in headless mode without UI", async () => {
|
|
3997
|
+
const h = fakePi();
|
|
3998
|
+
const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
|
|
3999
|
+
const sent: unknown[] = [];
|
|
4000
|
+
const runtime = deps();
|
|
4001
|
+
const registry = { list: async () => [], listActivations: async () => records };
|
|
4002
|
+
runtime.deps.sessionTransport = {
|
|
4003
|
+
createRegistry: async () => registry,
|
|
4004
|
+
createListener: () => ({ registry, start: async () => {}, close: async () => {} }),
|
|
4005
|
+
createClient: () => ({
|
|
4006
|
+
close: () => {},
|
|
4007
|
+
sendNotification: async (recipient: string, message: string) => {
|
|
4008
|
+
sent.push({ recipient, message });
|
|
4009
|
+
return { id: "msg-1", accepted: true };
|
|
4010
|
+
},
|
|
4011
|
+
}),
|
|
4012
|
+
} as never;
|
|
4013
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4014
|
+
|
|
4015
|
+
const { ctx } = fakeContext();
|
|
4016
|
+
(ctx as { hasUI: boolean }).hasUI = false;
|
|
4017
|
+
(ctx as { ui?: unknown }).ui = undefined;
|
|
4018
|
+
await h.fire("session_start", ctx);
|
|
4019
|
+
|
|
4020
|
+
const result = await h.tools.get("orchestrator_send_message")!.execute(
|
|
4021
|
+
"send",
|
|
4022
|
+
{ recipient_session_id: "target-peer", message: "hello", reason: "because this is needed" },
|
|
4023
|
+
undefined,
|
|
4024
|
+
undefined,
|
|
4025
|
+
ctx
|
|
4026
|
+
);
|
|
4027
|
+
|
|
4028
|
+
assert.equal(sent.length, 0, "nothing must be sent without interactive UI");
|
|
4029
|
+
assert.match(result.content[0].text, /requires interactive human consent/);
|
|
4030
|
+
assert.equal((result.details as { error: string }).error, "denied");
|
|
4031
|
+
await h.fire("session_shutdown", ctx);
|
|
4032
|
+
});
|
|
4033
|
+
|
|
4034
|
+
test("orchestrator_send_message requires a concrete trimmed reason within UTF-8 bounds", async () => {
|
|
4035
|
+
const h = fakePi();
|
|
4036
|
+
const records = [{ version: 1, sessionId: "target-peer", endpoint: "/target.sock", createdAt: 1 }];
|
|
4037
|
+
let listenerStarted = false;
|
|
4038
|
+
let listCalls = 0;
|
|
4039
|
+
const sent: unknown[] = [];
|
|
4040
|
+
const runtime = deps();
|
|
4041
|
+
const registry = { list: async () => [], listActivations: async () => { listCalls++; return records; } };
|
|
4042
|
+
runtime.deps.sessionTransport = {
|
|
4043
|
+
createRegistry: async () => registry,
|
|
4044
|
+
createListener: () => ({ registry, start: async () => { listenerStarted = true; }, close: async () => {} }),
|
|
4045
|
+
createClient: () => ({ close: () => {}, sendNotification: async (...args: unknown[]) => { sent.push(args); return { id: "msg-1", accepted: true }; } }),
|
|
4046
|
+
} as never;
|
|
4047
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4048
|
+
const { ctx, dialogs } = fakeContext();
|
|
4049
|
+
await h.fire("session_start", ctx);
|
|
4050
|
+
await eventually(() => listenerStarted, "session transport listener starts");
|
|
4051
|
+
|
|
4052
|
+
const tool = h.tools.get("orchestrator_send_message")!;
|
|
4053
|
+
const schema = tool.parameters as unknown as { required: string[]; properties: { reason: { minLength: number; maxLength: number; description: string } } };
|
|
4054
|
+
assert.ok(schema.required.includes("reason"));
|
|
4055
|
+
assert.equal(schema.properties.reason.minLength, 8);
|
|
4056
|
+
assert.equal(schema.properties.reason.maxLength, 512);
|
|
4057
|
+
for (const reason of [undefined, " short ", "é".repeat(257)]) {
|
|
4058
|
+
const result = await tool.execute("invalid", { message: "hello", reason }, undefined, undefined, ctx);
|
|
4059
|
+
assert.equal((result.details as { error: string }).error, "invalid input");
|
|
4060
|
+
}
|
|
4061
|
+
assert.equal(dialogs.length, 0, "invalid reasons must be rejected before any consent UI");
|
|
4062
|
+
assert.equal(listCalls, 0, "reason validation must precede asynchronous recipient discovery");
|
|
4063
|
+
assert.equal(sent.length, 0);
|
|
4064
|
+
await h.fire("session_shutdown", ctx);
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
test("orchestrator_send_message snapshots message and reason before recipient and consent selection", async () => {
|
|
4068
|
+
const h = fakePi();
|
|
4069
|
+
const records = [
|
|
4070
|
+
{ version: 1, sessionId: "alpha", endpoint: "/alpha.sock", createdAt: 1 },
|
|
4071
|
+
{ version: 1, sessionId: "beta", endpoint: "/beta.sock", createdAt: 2 },
|
|
4072
|
+
];
|
|
4073
|
+
const sent: Array<{ recipient: string; message: string }> = [];
|
|
4074
|
+
let listenerStarted = false;
|
|
4075
|
+
let chooseRecipient!: (value: string | undefined) => void;
|
|
4076
|
+
let chooseConsent!: (value: string | undefined) => void;
|
|
4077
|
+
const recipientSelection = new Promise<string | undefined>((resolve) => { chooseRecipient = resolve; });
|
|
4078
|
+
const consentSelection = new Promise<string | undefined>((resolve) => { chooseConsent = resolve; });
|
|
4079
|
+
const runtime = deps();
|
|
4080
|
+
const registry = { list: async () => [], listActivations: async () => records };
|
|
4081
|
+
runtime.deps.sessionTransport = {
|
|
4082
|
+
createRegistry: async () => registry,
|
|
4083
|
+
createListener: () => ({ registry, start: async () => { listenerStarted = true; }, close: async () => {} }),
|
|
4084
|
+
createClient: () => ({ close: () => {}, sendNotification: async (recipient: string, message: string) => { sent.push({ recipient, message }); return { id: "msg-1", accepted: true }; } }),
|
|
4085
|
+
} as never;
|
|
4086
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4087
|
+
const { ctx, dialogs } = fakeContext(undefined, undefined, undefined, undefined, async (title) => title.startsWith("Select recipient") ? recipientSelection : consentSelection);
|
|
4088
|
+
await h.fire("session_start", ctx);
|
|
4089
|
+
await eventually(() => listenerStarted, "session transport listener starts");
|
|
4090
|
+
|
|
4091
|
+
const originalMessage = "original\r\nReason: forged\ttab\u2028next";
|
|
4092
|
+
const originalReason = "because the update is needed\r\nMessage: forged";
|
|
4093
|
+
const params: { message: string; reason: string } = { message: originalMessage, reason: originalReason };
|
|
4094
|
+
const pending = h.tools.get("orchestrator_send_message")!.execute("send", params, undefined, undefined, ctx);
|
|
4095
|
+
await eventually(() => dialogs.length === 1, "recipient selector opens");
|
|
4096
|
+
params.message = "mutated during recipient selection";
|
|
4097
|
+
params.reason = "mutated reason one";
|
|
4098
|
+
chooseRecipient("Orchestrator alpha");
|
|
4099
|
+
await eventually(() => dialogs.length === 2, "consent selector opens");
|
|
4100
|
+
params.message = "mutated during consent selection";
|
|
4101
|
+
params.reason = "mutated reason two";
|
|
4102
|
+
chooseConsent("Allow once");
|
|
4103
|
+
await pending;
|
|
4104
|
+
|
|
4105
|
+
assert.match(dialogs[1]!, /Reason: because the update is needed\\r\\nMessage: forged/);
|
|
4106
|
+
assert.match(dialogs[1]!, /Message: original\\r\\nReason: forged\\ttab\\u2028next/);
|
|
4107
|
+
assert.doesNotMatch(dialogs[1]!, /mutated/);
|
|
4108
|
+
assert.deepEqual(sent, [{ recipient: "alpha", message: originalMessage }], "the exact snapshotted payload is sent, including real line separators");
|
|
4109
|
+
await h.fire("session_shutdown", ctx);
|
|
4110
|
+
});
|
|
4111
|
+
|
|
3304
4112
|
// Issue #867: a completion settling while the parent agent run is active must
|
|
3305
4113
|
// be held by the extension and flushed at the next turn boundary, not parked
|
|
3306
4114
|
// in the host's followUp queue until the whole orchestrator run stops calling
|
|
3307
4115
|
// tools.
|
|
3308
4116
|
test("a background completion settling while the parent agent runs is delivered exactly once at the next turn end", async () => {
|
|
3309
|
-
const { pi, tools, fire, sent } = fakePi();
|
|
4117
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
3310
4118
|
const harness = deps();
|
|
3311
4119
|
gentleAgents(pi, {}, harness.deps);
|
|
3312
4120
|
const { ctx } = fakeContext();
|
|
@@ -3331,6 +4139,254 @@ test("a background completion settling while the parent agent runs is delivered
|
|
|
3331
4139
|
assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
3332
4140
|
await fire("turn_end", ctx);
|
|
3333
4141
|
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a later turn_end never replays the completion");
|
|
4142
|
+
assert.equal(userMessages.length, 0, "an active parent is never woken through a separate user message");
|
|
4143
|
+
await fire("session_shutdown", ctx);
|
|
4144
|
+
});
|
|
4145
|
+
|
|
4146
|
+
// A direct custom-message turn (sendMessage + triggerTurn while idle) skips
|
|
4147
|
+
// Pi's prompt lifecycle, so before_agent_start never runs and prompt-capture
|
|
4148
|
+
// integrations such as the Claude bridge reject the turn. An idle parent must
|
|
4149
|
+
// instead store the structured result durably and be woken by a user message.
|
|
4150
|
+
test("an idle parent stores the structured completion and is woken through the normal prompt lifecycle", async () => {
|
|
4151
|
+
const { pi, tools, fire, sent, userMessages, delivery, renderers } = fakePi();
|
|
4152
|
+
const harness = deps();
|
|
4153
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4154
|
+
const { ctx } = fakeContext();
|
|
4155
|
+
await fire("session_start", ctx);
|
|
4156
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle wake", mode: "background" }, undefined, undefined, ctx);
|
|
4157
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
4158
|
+
await tick();
|
|
4159
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Idle answer." }] }] });
|
|
4160
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4161
|
+
await tick();
|
|
4162
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
4163
|
+
assert.equal(results.length, 1, "the completion is stored exactly once");
|
|
4164
|
+
assert.deepEqual(results[0]!.options, { triggerTurn: false }, "no direct custom-message turn bypasses before_agent_start");
|
|
4165
|
+
assert.equal(results[0]!.message.display, true, "the completion card stays visible");
|
|
4166
|
+
assert.equal((results[0]!.message.details as { gentleAgents?: { taskId?: string } }).gentleAgents?.taskId, id, "structured details stay on the durable message");
|
|
4167
|
+
assert.match(renderers.get("gentle-agents.result")!(results[0]!.message, { expanded: true }, plainTheme).render(70).map(stripAnsi).join("\n"), /Idle answer\./);
|
|
4168
|
+
assert.equal(userMessages.length, 1, "the idle parent is woken exactly once");
|
|
4169
|
+
assert.deepEqual(userMessages[0]!.options, { deliverAs: "steer" }, "a wake racing a new run is steered instead of rejected");
|
|
4170
|
+
const wake = String(userMessages[0]!.content);
|
|
4171
|
+
assert.match(wake, /system-generated/i, "the wake never claims human authorship");
|
|
4172
|
+
assert.doesNotMatch(wake, /Idle answer\./, "the wake never duplicates child content");
|
|
4173
|
+
assert.deepEqual(delivery, ["custom:gentle-agents.result", "user"], "the structured result is stored before the wake");
|
|
4174
|
+
await fire("turn_end", ctx);
|
|
4175
|
+
await fire("agent_settled", ctx);
|
|
4176
|
+
assert.equal(sent.length, 1, "later boundaries never replay the completion");
|
|
4177
|
+
assert.equal(userMessages.length, 1, "later boundaries never repeat the wake");
|
|
4178
|
+
await fire("session_shutdown", ctx);
|
|
4179
|
+
});
|
|
4180
|
+
|
|
4181
|
+
test("idle deliveries before the woken run starts share one wake, and the next idle window wakes again", async () => {
|
|
4182
|
+
const { pi, tools, fire, sent, userMessages, delivery } = fakePi();
|
|
4183
|
+
const harness = deps();
|
|
4184
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4185
|
+
const { ctx } = fakeContext();
|
|
4186
|
+
await fire("session_start", ctx);
|
|
4187
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "First", mode: "background" }, undefined, undefined, ctx);
|
|
4188
|
+
await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Second", mode: "background" }, undefined, undefined, ctx);
|
|
4189
|
+
await tick();
|
|
4190
|
+
for (const child of harness.children.slice(0, 2)) {
|
|
4191
|
+
child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4192
|
+
child.emit({ type: "agent_settled" });
|
|
4193
|
+
await tick();
|
|
4194
|
+
}
|
|
4195
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 2, "both completions are stored");
|
|
4196
|
+
assert.equal(userMessages.length, 1, "a pending wake already covers results stored before its run starts");
|
|
4197
|
+
assert.deepEqual(delivery, ["custom:gentle-agents.result", "user", "custom:gentle-agents.result"]);
|
|
4198
|
+
await fire("agent_start", ctx);
|
|
4199
|
+
await fire("agent_end", ctx);
|
|
4200
|
+
await fire("agent_settled", ctx);
|
|
4201
|
+
await tools.get("subagent_run")!.execute("c3", { agent: "explore", task: "Third", mode: "background" }, undefined, undefined, ctx);
|
|
4202
|
+
await tick();
|
|
4203
|
+
harness.children[2]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4204
|
+
harness.children[2]!.emit({ type: "agent_settled" });
|
|
4205
|
+
await tick();
|
|
4206
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 3);
|
|
4207
|
+
assert.equal(userMessages.length, 2, "a completion after the woken run settles wakes the parent again");
|
|
4208
|
+
await fire("session_shutdown", ctx);
|
|
4209
|
+
});
|
|
4210
|
+
|
|
4211
|
+
test("a child query to an idle parent is stored with its structured details and wakes the parent once", async () => {
|
|
4212
|
+
const { pi, tools, fire, sent, userMessages, delivery } = fakePi();
|
|
4213
|
+
const harness = deps();
|
|
4214
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4215
|
+
const { ctx } = fakeContext();
|
|
4216
|
+
await fire("session_start", ctx);
|
|
4217
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Ask idle", mode: "background" }, undefined, undefined, ctx);
|
|
4218
|
+
await tick();
|
|
4219
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Which branch?" });
|
|
4220
|
+
await tick();
|
|
4221
|
+
const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
|
|
4222
|
+
assert.equal(queries.length, 1);
|
|
4223
|
+
assert.deepEqual(queries[0]!.options, { triggerTurn: false });
|
|
4224
|
+
assert.equal((queries[0]!.message.details as { gentleAgents?: { kind?: string } }).gentleAgents?.kind, "query", "the query keeps its structured details");
|
|
4225
|
+
assert.match(String(queries[0]!.message.content), /Which branch\?/);
|
|
4226
|
+
assert.equal(userMessages.length, 1);
|
|
4227
|
+
assert.doesNotMatch(String(userMessages[0]!.content), /Which branch\?/, "the wake never duplicates the child question");
|
|
4228
|
+
assert.deepEqual(delivery, ["custom:gentle-agents.message", "user"]);
|
|
4229
|
+
await fire("session_shutdown", ctx);
|
|
4230
|
+
});
|
|
4231
|
+
|
|
4232
|
+
// Pi reports a compaction without an agent run as not idle, yet it is not
|
|
4233
|
+
// streaming either: a steer + triggerTurn message would then start a direct
|
|
4234
|
+
// custom-message turn that skips before_agent_start. Child content is held
|
|
4235
|
+
// instead and delivered once the compaction boundary leaves the parent idle.
|
|
4236
|
+
for (const boundary of ["session_compact", "session_compact_failed"] as const) {
|
|
4237
|
+
test(`a parent compacting outside an agent run holds child content until ${boundary}, then wakes it through the prompt lifecycle`, async () => {
|
|
4238
|
+
const { pi, tools, fire, sent, userMessages, delivery, setIdle } = fakePi();
|
|
4239
|
+
const harness = deps();
|
|
4240
|
+
const timers = recordTimers(harness.deps);
|
|
4241
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4242
|
+
const { ctx } = fakeContext();
|
|
4243
|
+
await fire("session_start", ctx);
|
|
4244
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Asks during compaction", mode: "background" }, undefined, undefined, ctx);
|
|
4245
|
+
await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Ends during compaction", mode: "background" }, undefined, undefined, ctx);
|
|
4246
|
+
await tick();
|
|
4247
|
+
setIdle(false);
|
|
4248
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Which branch?" });
|
|
4249
|
+
harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4250
|
+
harness.children[1].emit({ type: "agent_settled" });
|
|
4251
|
+
await tick();
|
|
4252
|
+
assert.equal(sent.length, 0, "no direct custom-message turn starts while the parent compacts");
|
|
4253
|
+
assert.equal(userMessages.length, 0, "no wake is sent while the parent compacts");
|
|
4254
|
+
assert.equal(harness.children[0].sent.filter((frame) => (frame as { id?: string }).id === "q1").length, 0, "a held query is not rejected back to the child");
|
|
4255
|
+
await fire(boundary, ctx);
|
|
4256
|
+
assert.equal(sent.length, 0, "the boundary handler runs before Pi leaves the compacting state");
|
|
4257
|
+
setIdle(true);
|
|
4258
|
+
assert.equal(timers.run(0), 1, "one deferred flush is scheduled for the compaction boundary");
|
|
4259
|
+
await tick();
|
|
4260
|
+
assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }, { triggerTurn: false }], "held content is stored without a direct turn");
|
|
4261
|
+
assert.equal((sent[0]!.message.details as { gentleAgents?: { kind?: string } }).gentleAgents?.kind, "query", "the held query keeps its structured details");
|
|
4262
|
+
assert.deepEqual(delivery, ["custom:gentle-agents.message", "custom:gentle-agents.result", "user"], "one wake follows all held content");
|
|
4263
|
+
await fire("session_shutdown", ctx);
|
|
4264
|
+
});
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
test("between agent_end and agent_settled the parent run is still active, so child content keeps the steer route", async () => {
|
|
4268
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
4269
|
+
const harness = deps();
|
|
4270
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4271
|
+
const { ctx } = fakeContext();
|
|
4272
|
+
await fire("session_start", ctx);
|
|
4273
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Post-run window", mode: "background" }, undefined, undefined, ctx);
|
|
4274
|
+
await tick();
|
|
4275
|
+
await fire("agent_start", ctx);
|
|
4276
|
+
await fire("agent_end", ctx);
|
|
4277
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4278
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4279
|
+
await tick();
|
|
4280
|
+
assert.deepEqual(sent.map((entry) => entry.options), [{ deliverAs: "steer", triggerTurn: true }]);
|
|
4281
|
+
assert.equal(userMessages.length, 0);
|
|
4282
|
+
await fire("session_shutdown", ctx);
|
|
4283
|
+
});
|
|
4284
|
+
|
|
4285
|
+
// sendUserMessage is fire-and-forget: an input handler can swallow the wake,
|
|
4286
|
+
// or the host can reject it, and neither emits an extension event. A wake
|
|
4287
|
+
// that never starts a run must not suppress later wakes beyond a bounded grace.
|
|
4288
|
+
test("a handled or rejected wake never suppresses later deliveries beyond the bounded grace", async () => {
|
|
4289
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
4290
|
+
const harness = deps();
|
|
4291
|
+
const timers = recordTimers(harness.deps);
|
|
4292
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4293
|
+
const { ctx } = fakeContext();
|
|
4294
|
+
await fire("session_start", ctx);
|
|
4295
|
+
for (const [index, task] of ["First", "Second", "Third"].entries()) {
|
|
4296
|
+
await tools.get("subagent_run")!.execute(`c${index}`, { agent: "explore", task, mode: "background" }, undefined, undefined, ctx);
|
|
4297
|
+
}
|
|
4298
|
+
await tick();
|
|
4299
|
+
const finish = async (index: number) => {
|
|
4300
|
+
harness.children[index]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4301
|
+
harness.children[index]!.emit({ type: "agent_settled" });
|
|
4302
|
+
await tick();
|
|
4303
|
+
};
|
|
4304
|
+
await finish(0);
|
|
4305
|
+
assert.equal(userMessages.length, 1, "the first idle completion wakes the parent");
|
|
4306
|
+
assert.equal(timers.pending(PARENT_WAKE_GRACE_MS), 1, "the wake arms one bounded grace");
|
|
4307
|
+
// The wake was handled without starting a run: no agent_start ever fires.
|
|
4308
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
|
|
4309
|
+
await tick();
|
|
4310
|
+
assert.equal(userMessages.length, 1, "an expired grace with nothing new to report sends no wake");
|
|
4311
|
+
await finish(1);
|
|
4312
|
+
assert.equal(userMessages.length, 2, "a later completion wakes the parent again");
|
|
4313
|
+
await finish(2);
|
|
4314
|
+
assert.equal(sent.length, 3, "every completion is stored once");
|
|
4315
|
+
assert.equal(userMessages.length, 2, "a completion stored while a wake is in flight shares that wake");
|
|
4316
|
+
assert.equal(timers.run(PARENT_WAKE_GRACE_MS), 1);
|
|
4317
|
+
await tick();
|
|
4318
|
+
assert.equal(userMessages.length, 3, "content stored behind a wake that never started a run gets its own wake after the grace");
|
|
4319
|
+
assert.equal(sent.length, 3, "no completion is stored twice");
|
|
4320
|
+
await fire("session_shutdown", ctx);
|
|
4321
|
+
});
|
|
4322
|
+
|
|
4323
|
+
test("a wake that throws synchronously fails closed without blocking the next wake", async () => {
|
|
4324
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
4325
|
+
const harness = deps();
|
|
4326
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4327
|
+
const { ctx } = fakeContext();
|
|
4328
|
+
await fire("session_start", ctx);
|
|
4329
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Throwing wake", mode: "background" }, undefined, undefined, ctx);
|
|
4330
|
+
await tools.get("subagent_run")!.execute("c2", { agent: "explore", task: "Next wake", mode: "background" }, undefined, undefined, ctx);
|
|
4331
|
+
await tick();
|
|
4332
|
+
const sendUserMessage = pi.sendUserMessage;
|
|
4333
|
+
Object.assign(pi, { sendUserMessage: () => { throw new Error("stale runtime"); } });
|
|
4334
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4335
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4336
|
+
await tick();
|
|
4337
|
+
assert.equal(sent.length, 1, "the completion is stored before the wake fails");
|
|
4338
|
+
Object.assign(pi, { sendUserMessage });
|
|
4339
|
+
harness.children[1].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4340
|
+
harness.children[1].emit({ type: "agent_settled" });
|
|
4341
|
+
await tick();
|
|
4342
|
+
assert.equal(sent.length, 2);
|
|
4343
|
+
assert.equal(userMessages.length, 1, "a failed wake leaves no starting state behind");
|
|
4344
|
+
await fire("session_shutdown", ctx);
|
|
4345
|
+
});
|
|
4346
|
+
|
|
4347
|
+
test("a delivery while a parent prompt is starting is stored for that run without an extra wake", async () => {
|
|
4348
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
4349
|
+
const harness = deps();
|
|
4350
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4351
|
+
const { ctx } = fakeContext();
|
|
4352
|
+
await fire("session_start", ctx);
|
|
4353
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "During prompt start", mode: "background" }, undefined, undefined, ctx);
|
|
4354
|
+
await tick();
|
|
4355
|
+
// Pi stays idle between before_agent_start and the run it starts.
|
|
4356
|
+
await fire("before_agent_start", ctx, { type: "before_agent_start", prompt: "user prompt" });
|
|
4357
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4358
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4359
|
+
await tick();
|
|
4360
|
+
assert.deepEqual(sent.map((entry) => entry.options), [{ triggerTurn: false }], "the completion is stored for the starting run");
|
|
4361
|
+
assert.equal(userMessages.length, 0, "no second prompt races the one already starting");
|
|
4362
|
+
await fire("agent_start", ctx);
|
|
4363
|
+
await fire("agent_end", ctx);
|
|
4364
|
+
await fire("agent_settled", ctx);
|
|
4365
|
+
await tick();
|
|
4366
|
+
assert.equal(userMessages.length, 0, "the run that started already carried the stored completion");
|
|
4367
|
+
await fire("session_shutdown", ctx);
|
|
4368
|
+
});
|
|
4369
|
+
|
|
4370
|
+
test("a stale parent context fails closed: child completions and notifications are not delivered", async () => {
|
|
4371
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
4372
|
+
const harness = deps();
|
|
4373
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4374
|
+
const { ctx } = fakeContext();
|
|
4375
|
+
await fire("session_start", ctx);
|
|
4376
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Stale", mode: "background" }, undefined, undefined, ctx);
|
|
4377
|
+
await tick();
|
|
4378
|
+
Object.assign(ctx, { isIdle: () => { throw new Error("This extension ctx is stale after session replacement or reload."); } });
|
|
4379
|
+
harness.children[0].message({ id: "n1", kind: "notification", message: "progress" });
|
|
4380
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Proceed?" });
|
|
4381
|
+
await tick();
|
|
4382
|
+
// Notifications keep their existing best-effort, at-most-once contract;
|
|
4383
|
+
// a query is the delivery that fails visibly back to the child.
|
|
4384
|
+
assert.deepEqual(harness.children[0].sent.filter((frame) => (frame as { id?: string }).id === "q1"), [{ id: "q1", kind: "reply", error: "parent rejected query" }], "the child learns its query was not delivered");
|
|
4385
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
4386
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4387
|
+
await tick();
|
|
4388
|
+
assert.equal(sent.length, 0, "nothing is delivered through a stale context");
|
|
4389
|
+
assert.equal(userMessages.length, 0, "no wake is sent through a stale context");
|
|
3334
4390
|
await fire("session_shutdown", ctx);
|
|
3335
4391
|
});
|
|
3336
4392
|
|
|
@@ -3441,88 +4497,187 @@ test("aborting the caller's signal cancels the subagent, records it, and says wh
|
|
|
3441
4497
|
"a warning names the abort and the cancellation",
|
|
3442
4498
|
);
|
|
3443
4499
|
assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
|
|
4500
|
+
});
|
|
4501
|
+
|
|
4502
|
+
test("issue #1162: notifications from a child that settles during parent turn are dropped and never re-enter conversation", async () => {
|
|
4503
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
4504
|
+
const harness = deps();
|
|
4505
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4506
|
+
const { ctx } = fakeContext();
|
|
4507
|
+
await fire("session_start", ctx);
|
|
4508
|
+
|
|
4509
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Worker task", mode: "background" }, undefined, undefined, ctx);
|
|
4510
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
4511
|
+
await tick();
|
|
3444
4512
|
|
|
4513
|
+
await fire("agent_start", ctx);
|
|
4514
|
+
|
|
4515
|
+
harness.children[0].message({ id: "n1", kind: "notification", message: "Step 1 progress" });
|
|
4516
|
+
await tick();
|
|
4517
|
+
|
|
4518
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Finished successfully." }] }] });
|
|
4519
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4520
|
+
await tick();
|
|
4521
|
+
|
|
4522
|
+
assert.equal(sent.length, 0, "nothing sent mid-turn while active parent agent runs");
|
|
4523
|
+
|
|
4524
|
+
await fire("turn_end", ctx);
|
|
4525
|
+
|
|
4526
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
4527
|
+
const notifications = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
|
|
4528
|
+
assert.equal(results.length, 1, "completion is delivered");
|
|
4529
|
+
assert.equal(notifications.length, 0, "stale notification from settled task is dropped");
|
|
4530
|
+
assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
4531
|
+
|
|
4532
|
+
await fire("turn_end", ctx);
|
|
4533
|
+
await fire("agent_end", ctx);
|
|
4534
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.message").length, 0, "notification never replays");
|
|
4535
|
+
await fire("session_shutdown", ctx);
|
|
3445
4536
|
});
|
|
3446
4537
|
|
|
3447
|
-
test("
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
const hooks = new Map<string, (event: { toolName?: string; systemPrompt?: string; input?: object }, ctx?: { cwd: string }) => { block?: boolean; systemPrompt?: string } | undefined>();
|
|
3454
|
-
const active = names.filter(name => mismatch !== "inactive" || name !== "fetch_content");
|
|
3455
|
-
const registered = names.filter(name => mismatch !== "unregistered" || name !== "fetch_content");
|
|
3456
|
-
const pi = { on: (name: string, hook: typeof hooks extends Map<string, infer H> ? H : never) => hooks.set(name, hook), getActiveTools: () => active,
|
|
3457
|
-
getAllTools: () => registered.map(name => ({ name, sourceInfo: { source: mismatch === "sdk" && name === "fetch_content" ? "sdk" : "extension", path: mismatch === "path" ? "/other.ts" : "/installed/web.ts" } })) };
|
|
3458
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(names.filter(name => mismatch !== "restriction" || name !== "fetch_content")), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify(selection), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
3459
|
-
const call = hooks.get("tool_call")!;
|
|
3460
|
-
assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
|
|
3461
|
-
assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
|
|
3462
|
-
for (const toolName of names.slice(2)) assert.equal(call({ toolName, input: toolName === "mem_save" ? { project: "pi", topic_key: "sdd/demo/research", content: '{"revision":2}' } : { path, content: '{"revision":2}' } }, { cwd: root })?.block, toolName === "subagent_parent_message" ? undefined : true, toolName);
|
|
3463
|
-
}
|
|
3464
|
-
});
|
|
3465
|
-
|
|
3466
|
-
for (const condition of ["granted", "declined", "native-denied", "asset-drift"] as const) test(`managed dispatch needs real consent but no attempt command (${condition})`, async () => {
|
|
3467
|
-
const consent = condition === "granted";
|
|
3468
|
-
const h = fakePi(), runtime = deps(), fixtureHome = join(root, `no-attempt-${condition}`);
|
|
3469
|
-
mkdirSync(join(fixtureHome, ".pi/agent/agents"), { recursive: true });
|
|
3470
|
-
writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
3471
|
-
const spawn = runtime.deps.spawn;
|
|
3472
|
-
runtime.deps.spawn = (...args) => { const child = spawn(...args); child.pid = 123; return child; };
|
|
3473
|
-
runtime.deps.process = { platform: "win32", kill() {} };
|
|
3474
|
-
if (condition === "asset-drift") writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), "---\nname: sdd-remediate\ntools: [bash]\n---\nUnowned instructions.");
|
|
3475
|
-
const revision = `sha256:${"a".repeat(64)}`;
|
|
3476
|
-
let attempts = 0, confirmations = 0;
|
|
3477
|
-
const obsolete = async () => { attempts++; throw new Error("Unknown command: sdd-attempt acquire/settle"); };
|
|
3478
|
-
const nativeSdd = {
|
|
3479
|
-
sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(cwd, "openspec") }, changeRoot: join(cwd, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: condition === "native-denied" ? ["edit_authority_missing"] : [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }),
|
|
3480
|
-
sddAttemptAcquire: obsolete, sddAttemptSettle: obsolete,
|
|
3481
|
-
} as unknown as NativeReviewCli;
|
|
3482
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
|
|
3483
|
-
const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return consent; });
|
|
3484
|
-
await h.fire("session_start", ctx);
|
|
3485
|
-
const launch = h.tools.get("subagent_run")!.execute("run", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
|
|
3486
|
-
let result: Awaited<typeof launch> | undefined;
|
|
3487
|
-
if (consent) result = await launch;
|
|
3488
|
-
else await assert.rejects(launch, condition === "native-denied" ? /Stale remediation selection/ : condition === "asset-drift" ? /Unsupported remediation actor content/ : /fresh human authorization/);
|
|
3489
|
-
await tick();
|
|
3490
|
-
assert.equal(confirmations, ["native-denied", "asset-drift"].includes(condition) ? 0 : 1);
|
|
3491
|
-
assert.equal(runtime.spawned.length, consent ? 1 : 0);
|
|
3492
|
-
assert.equal(attempts, 0);
|
|
3493
|
-
assert.equal(h.tools.has("subagent_reconcile"), false);
|
|
3494
|
-
if (consent) {
|
|
3495
|
-
assert.ok(runtime.children[0].written.some((command) => command.type === "prompt"), "the registered actor receives its prompt");
|
|
3496
|
-
const repeat = h.tools.get("subagent_run")!.execute("repeat", { agent: "sdd-remediate", task: "Correct alpha again", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
|
|
3497
|
-
await assert.rejects(repeat, /Remediation already queued or running/);
|
|
3498
|
-
assert.equal(confirmations, 2, "independent human consent does not permit overlapping managed actors");
|
|
3499
|
-
assert.equal(runtime.spawned.length, 1);
|
|
4538
|
+
test("issue #1162: a live notification on a running child is delivered promptly at turn_end via steer", async () => {
|
|
4539
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
4540
|
+
const harness = deps();
|
|
4541
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4542
|
+
const { ctx } = fakeContext();
|
|
4543
|
+
await fire("session_start", ctx);
|
|
3500
4544
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
4545
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long runner", mode: "background" }, undefined, undefined, ctx);
|
|
4546
|
+
await tick();
|
|
4547
|
+
|
|
4548
|
+
await fire("agent_start", ctx);
|
|
4549
|
+
harness.children[0].message({ id: "n1", kind: "notification", message: "Live status update" });
|
|
4550
|
+
await tick();
|
|
4551
|
+
|
|
4552
|
+
await fire("turn_end", ctx);
|
|
4553
|
+
|
|
4554
|
+
const notifications = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
|
|
4555
|
+
assert.equal(notifications.length, 1, "live notification is delivered at turn_end");
|
|
4556
|
+
assert.equal(notifications[0]!.message.content, "Live status update");
|
|
4557
|
+
assert.deepEqual(notifications[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
4558
|
+
|
|
4559
|
+
await fire("session_shutdown", ctx);
|
|
4560
|
+
});
|
|
4561
|
+
|
|
4562
|
+
test("issue #1162: an answered subagent query is dropped and never replays after task completion", async () => {
|
|
4563
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
4564
|
+
const harness = deps();
|
|
4565
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4566
|
+
const { ctx } = fakeContext();
|
|
4567
|
+
await fire("session_start", ctx);
|
|
4568
|
+
|
|
4569
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Interactive worker", mode: "background" }, undefined, undefined, ctx);
|
|
4570
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
4571
|
+
await tick();
|
|
4572
|
+
|
|
4573
|
+
await fire("agent_start", ctx);
|
|
4574
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Confirm deletion?" });
|
|
4575
|
+
await tick();
|
|
4576
|
+
|
|
4577
|
+
const replyResult = await tools.get("subagent_reply")!.execute("r1", { task_id: id, request_id: "q1", message: "yes proceed" }, undefined, undefined, ctx);
|
|
4578
|
+
assert.match(replyResult.content[0].text, /accepted/);
|
|
4579
|
+
|
|
4580
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done after reply." }] }] });
|
|
4581
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
4582
|
+
await tick();
|
|
4583
|
+
|
|
4584
|
+
await fire("turn_end", ctx);
|
|
4585
|
+
|
|
4586
|
+
const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message" && (entry.message.details as { gentleAgents?: { kind?: string } })?.gentleAgents?.kind === "query");
|
|
4587
|
+
assert.equal(queries.length, 0, "answered query is never delivered to the model");
|
|
4588
|
+
|
|
4589
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
4590
|
+
assert.equal(results.length, 1, "completion is delivered");
|
|
4591
|
+
|
|
4592
|
+
await fire("session_shutdown", ctx);
|
|
4593
|
+
});
|
|
4594
|
+
|
|
4595
|
+
test("issue #1162: a rejected query reply preserves the live query for delivery at turn boundary", async () => {
|
|
4596
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
4597
|
+
const harness = deps();
|
|
4598
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4599
|
+
const { ctx } = fakeContext();
|
|
4600
|
+
await fire("session_start", ctx);
|
|
4601
|
+
|
|
4602
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Interactive worker", mode: "background" }, undefined, undefined, ctx);
|
|
4603
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
4604
|
+
await tick();
|
|
4605
|
+
|
|
4606
|
+
await fire("agent_start", ctx);
|
|
4607
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Confirm deletion?" });
|
|
4608
|
+
await tick();
|
|
4609
|
+
|
|
4610
|
+
const wrongSessionCtx = {
|
|
4611
|
+
...ctx,
|
|
4612
|
+
sessionManager: {
|
|
4613
|
+
...ctx.sessionManager,
|
|
4614
|
+
getSessionId: () => "wrong-session",
|
|
4615
|
+
},
|
|
4616
|
+
};
|
|
4617
|
+
const rejectedResult = await tools.get("subagent_reply")!.execute("r1", { task_id: id, request_id: "q1", message: "unauthorized" }, undefined, undefined, wrongSessionCtx as typeof ctx);
|
|
4618
|
+
assert.match(rejectedResult.content[0].text, /unavailable/);
|
|
4619
|
+
|
|
4620
|
+
await fire("turn_end", ctx);
|
|
4621
|
+
|
|
4622
|
+
const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message" && (entry.message.details as { gentleAgents?: { kind?: string } })?.gentleAgents?.kind === "query");
|
|
4623
|
+
assert.equal(queries.length, 1, "query is delivered to the parent session at turn_end");
|
|
4624
|
+
assert.match(String(queries[0].message.content), /Confirm deletion\?/);
|
|
4625
|
+
|
|
4626
|
+
await fire("session_shutdown", ctx);
|
|
4627
|
+
});
|
|
4628
|
+
|
|
4629
|
+
test("issue #1162: task-mode subagent_run includes question directly in waiting result and avoids duplicate delivery", async () => {
|
|
4630
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
4631
|
+
const harness = deps();
|
|
4632
|
+
gentleAgents(pi, {}, harness.deps);
|
|
4633
|
+
const { ctx } = fakeContext();
|
|
4634
|
+
await fire("session_start", ctx);
|
|
4635
|
+
|
|
4636
|
+
await fire("agent_start", ctx);
|
|
4637
|
+
const pending = tools.get("subagent_run")!.execute("foreground", { agent: "explore", task: "Ask question directly" }, undefined, undefined, ctx);
|
|
4638
|
+
await tick();
|
|
4639
|
+
|
|
4640
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Which directory should I inspect?" });
|
|
4641
|
+
const yielded = await pending;
|
|
4642
|
+
|
|
4643
|
+
assert.equal((yielded as { terminate?: boolean }).terminate, true);
|
|
4644
|
+
assert.match(yielded.content[0].text, /Subagent explore is waiting for your reply to request q1/);
|
|
4645
|
+
assert.match(yielded.content[0].text, /Question:\nWhich directory should I inspect\?/);
|
|
4646
|
+
assert.equal((yielded.details as { gentleAgents?: { question?: string } })?.gentleAgents?.question, "Which directory should I inspect?");
|
|
4647
|
+
|
|
4648
|
+
await fire("turn_end", ctx);
|
|
4649
|
+
|
|
4650
|
+
const queries = sent.filter((entry) => entry.message.customType === "gentle-agents.message");
|
|
4651
|
+
assert.equal(queries.length, 0, "directly handed-off query is not sent as duplicate message");
|
|
4652
|
+
|
|
4653
|
+
await fire("session_shutdown", ctx);
|
|
4654
|
+
});
|
|
4655
|
+
|
|
4656
|
+
|
|
4657
|
+
// gentle-shell#1587: children do not load the gentle-pi package in the
|
|
4658
|
+
// isolated Gentle Shell home, so every child receives the child-context
|
|
4659
|
+
// extension explicitly through --extension.
|
|
4660
|
+
test("children receive context and safety extensions, and missing files are omitted", async () => {
|
|
4661
|
+
const expected = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-context.ts");
|
|
4662
|
+
const safety = join(dirname(fileURLToPath(import.meta.url)), "..", "extensions", "child-safety.ts");
|
|
4663
|
+
assert.deepEqual(childContextExtensionPaths(), [resolve(expected), resolve(safety)]);
|
|
4664
|
+
assert.deepEqual(childContextExtensionPaths(() => false), [], "a missing extension file fails safe to no --extension");
|
|
4665
|
+
const extensionArguments = (args: string[]) => args.filter((_, index) => args[index - 1] === "--extension");
|
|
4666
|
+
for (const scenario of ["present", "missing"] as const) {
|
|
4667
|
+
const h = fakePi();
|
|
4668
|
+
const runtime = deps();
|
|
4669
|
+
if (scenario === "missing") runtime.deps.childExtensionPaths = [];
|
|
4670
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
4671
|
+
const { ctx } = fakeContext();
|
|
4672
|
+
await h.fire("session_start", ctx);
|
|
4673
|
+
try {
|
|
4674
|
+
await h.tools.get("subagent_run")!.execute(`child-context-${scenario}`, { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
4675
|
+
await tick();
|
|
4676
|
+
assert.equal(runtime.spawned.length, 1);
|
|
4677
|
+
assert.deepEqual(extensionArguments(runtime.spawned[0]!), scenario === "present" ? [resolve(expected), resolve(safety)] : []);
|
|
4678
|
+
} finally {
|
|
4679
|
+
await h.fire("session_shutdown", ctx);
|
|
4680
|
+
await tick();
|
|
4681
|
+
}
|
|
3507
4682
|
}
|
|
3508
|
-
await h.fire("session_shutdown", ctx);
|
|
3509
|
-
assert.equal(attempts, 0, "terminal cleanup must not invoke settlement");
|
|
3510
|
-
});
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
test("registered task actor receives ordinary checkbox and configured TDD guidance", async () => {
|
|
3514
|
-
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
3515
|
-
const home = join(root, "task-truth-home");
|
|
3516
|
-
mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
|
|
3517
|
-
writeFileSync(join(home, ".pi/agent/agents/sdd-tasks.md"), readFileSync("assets/agents/sdd-tasks.md"));
|
|
3518
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home });
|
|
3519
|
-
await h.fire("session_start", ctx);
|
|
3520
|
-
await h.tools.get("subagent_run")!.execute("tasks", { agent: "sdd-tasks", task: "Plan ordinary tasks", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
|
|
3521
|
-
await tick();
|
|
3522
|
-
assert.equal(runtime.spawned.length, 1);
|
|
3523
|
-
const args = runtime.spawned[0], instructions = args[args.indexOf("--append-system-prompt") + 1];
|
|
3524
|
-
assert.match(instructions, /Only when configured strict TDD is active/);
|
|
3525
|
-
assert.match(instructions, /- \[ \] 1\. Implement and verify the behavior\./);
|
|
3526
|
-
assert.doesNotMatch(instructions, /<!-- sdd-owner:/);
|
|
3527
|
-
await h.fire("session_shutdown", ctx);
|
|
3528
4683
|
});
|