gentle-pi 3.7.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 +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- 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 +429 -262
- 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 +96 -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/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 +49 -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-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 +49 -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 +378 -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/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- 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 +7 -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-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
11
|
import { SessionChanges, type SessionChangeEvidence } from "../lib/session-changes.ts";
|
|
11
|
-
import test, { after, afterEach, mock } from "node:test";
|
|
12
|
+
import test, { after, afterEach, before, mock } from "node:test";
|
|
12
13
|
import type { TestContext } from "node:test";
|
|
13
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,
|
|
@@ -1776,6 +1733,56 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
1776
1733
|
});
|
|
1777
1734
|
}
|
|
1778
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
|
+
|
|
1779
1786
|
// C1 diagnostics (odd/tasks/usage-click-and-changes-attribution.md): the
|
|
1780
1787
|
// guard chain's posture is unchanged (spawn-gated registration stays, a
|
|
1781
1788
|
// parent decision) -- these only verify each drop explains itself once, in
|
|
@@ -2080,7 +2087,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
2080
2087
|
children[2]!.emit({ type: "agent_settled" });
|
|
2081
2088
|
await permission.result;
|
|
2082
2089
|
|
|
2083
|
-
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."];
|
|
2084
2091
|
assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
|
|
2085
2092
|
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
2086
2093
|
for (const [index, fixture] of ["task", "background", "permission"].entries()) {
|
|
@@ -2204,6 +2211,11 @@ test("foreign clone tool requires consent before queueing and never enters paren
|
|
|
2204
2211
|
await assert.rejects(run.execute("wrong-selector", { agent: "explore", task: "Map", workspace_root: foreign, mode: "background" }, undefined, undefined, ctx), /same Git clone/);
|
|
2205
2212
|
await assert.rejects(run.execute("both", { agent: "explore", task: "Map", workspace_root: parent, repository_root: foreign, mode: "background" }, undefined, undefined, ctx), /mutually exclusive/);
|
|
2206
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");
|
|
2207
2219
|
const pending = run.execute("foreign", { agent: "explore", task: "Map", repository_root: foreign, mode: "background" }, undefined, undefined, ctx);
|
|
2208
2220
|
await tick();
|
|
2209
2221
|
assert.equal(prompts, 1);
|
|
@@ -2441,7 +2453,7 @@ test("an interactive non-Git umbrella can target an independent repository witho
|
|
|
2441
2453
|
} finally { rmSync(fixture, { recursive: true, force: true }); }
|
|
2442
2454
|
});
|
|
2443
2455
|
|
|
2444
|
-
test("foreign selector rejects RPC
|
|
2456
|
+
test("foreign selector rejects RPC and retired SDD selections before consent", async () => {
|
|
2445
2457
|
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "foreign-callers-")));
|
|
2446
2458
|
const parent = join(fixture, "parent"), foreign = join(fixture, "foreign"), template = join(fixture, "template");
|
|
2447
2459
|
mkdirSync(template);
|
|
@@ -2453,7 +2465,6 @@ test("foreign selector rejects RPC even with UI and rejects SDD/remediation befo
|
|
|
2453
2465
|
mkdirSync(join(agentHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2454
2466
|
writeFileSync(join(agentHome, ".pi", "agent", "agents", "explore.md"), "---\ndescription: explore\ntools: [read]\n---\nExplore");
|
|
2455
2467
|
writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nApply");
|
|
2456
|
-
writeFileSync(join(agentHome, ".pi", "agent", "agents", "sdd-research.md"), "---\ndescription: research\ntools: [read]\n---\nResearch");
|
|
2457
2468
|
runtime.deps.home = agentHome;
|
|
2458
2469
|
gentleAgents(h.pi, {}, runtime.deps);
|
|
2459
2470
|
const { ctx, dialogs } = fakeContext();
|
|
@@ -2466,10 +2477,9 @@ test("foreign selector rejects RPC even with UI and rejects SDD/remediation befo
|
|
|
2466
2477
|
ctx.mode = "print";
|
|
2467
2478
|
await assert.rejects(run.execute("print", { ...base, mode: "task" }, undefined, undefined, ctx), /interactive parent session/);
|
|
2468
2479
|
ctx.mode = "tui";
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
assert.equal(existsSync(join(agentHome, ".pi", "agent", "sessions")), false, "foreign SDD rejection must precede child session directory creation");
|
|
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");
|
|
2473
2483
|
const childHost = fakePi();
|
|
2474
2484
|
gentleAgents(childHost.pi, { GENTLE_PI_AGENTS_CHILD: "1" }, runtime.deps);
|
|
2475
2485
|
assert.equal(childHost.tools.has("subagent_run"), false, "child-originated foreign launches have no delegation tool");
|
|
@@ -2564,39 +2574,173 @@ test("explicit child roots launch and continue in the actual cwd, persist withou
|
|
|
2564
2574
|
await tick();
|
|
2565
2575
|
});
|
|
2566
2576
|
|
|
2567
|
-
test("SDD
|
|
2577
|
+
test("retired SDD agent names and selection are rejected before dispatch", async () => {
|
|
2568
2578
|
const h = fakePi();
|
|
2569
2579
|
const runtime = deps();
|
|
2570
2580
|
const fixtureHome = join(root, "sdd-selection-home");
|
|
2571
2581
|
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2572
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");
|
|
2573
2584
|
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome });
|
|
2574
2585
|
const { ctx } = fakeContext();
|
|
2575
2586
|
await h.fire("session_start", ctx);
|
|
2576
|
-
const
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
const
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
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);
|
|
2586
2598
|
await tick();
|
|
2587
|
-
|
|
2588
|
-
|
|
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
|
+
}
|
|
2589
2605
|
assert.equal(runtime.spawned.length, 1);
|
|
2590
|
-
await h.tools.get("subagent_continue")!.execute("continue", {
|
|
2591
|
-
task_id: taskId, prompt: "Apply beta", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
|
|
2592
|
-
sdd_change: { changeName: "beta", workspaceRoot: cwd, phase: "apply" },
|
|
2593
|
-
}, undefined, undefined, ctx);
|
|
2594
|
-
await tick();
|
|
2595
|
-
const second = runtime.spawned[1]!;
|
|
2596
|
-
assert.deepEqual(JSON.parse(second[second.indexOf("--gentle-sdd-change") + 1]!), { changeName: "beta", workspaceRoot: cwd, phase: "apply" });
|
|
2597
2606
|
await h.fire("session_shutdown", ctx);
|
|
2598
2607
|
});
|
|
2599
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
|
+
|
|
2600
2744
|
test("ordinary non-Git tasks still continue in their original cwd without registering a worktree", async () => {
|
|
2601
2745
|
const h = fakePi();
|
|
2602
2746
|
const runtime = deps();
|
|
@@ -2618,34 +2762,36 @@ test("ordinary non-Git tasks still continue in their original cwd without regist
|
|
|
2618
2762
|
await tick();
|
|
2619
2763
|
});
|
|
2620
2764
|
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
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;
|
|
2632
2779
|
return child;
|
|
2633
|
-
}
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
});
|
|
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
|
+
}
|
|
2649
2795
|
|
|
2650
2796
|
test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
|
|
2651
2797
|
assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
|
|
@@ -2873,7 +3019,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
2873
3019
|
harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
|
|
2874
3020
|
harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
|
|
2875
3021
|
await tick();
|
|
2876
|
-
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 │$/);
|
|
2877
3023
|
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
|
|
2878
3024
|
harness.children[0].emit({ type: "agent_settled" });
|
|
2879
3025
|
const result = await running;
|
|
@@ -2889,6 +3035,33 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
2889
3035
|
assert.match(tools.get("subagent_run")!.renderCall({ agent: "explore" }, plainTheme).render(60).join(""), /❀ agent run · explore/);
|
|
2890
3036
|
});
|
|
2891
3037
|
|
|
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();
|
|
3040
|
+
const harness = deps();
|
|
3041
|
+
gentleAgents(pi, {}, harness.deps);
|
|
3042
|
+
const { ctx } = fakeContext();
|
|
3043
|
+
await fire("session_start", ctx);
|
|
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
|
+
|
|
2892
3065
|
test("the Agents widget never registers a sidebar rail part and stays visible even while the fullscreen sidebar owns the host", async () => {
|
|
2893
3066
|
const { pi, tools, fire } = fakePi();
|
|
2894
3067
|
const harness = deps();
|
|
@@ -2941,9 +3114,9 @@ test("background runs return at once; status, result, send_message, cancel, and
|
|
|
2941
3114
|
assert.equal(sent[0].message.display, true, "completion cards remain visible");
|
|
2942
3115
|
// The completion path must never regress to "followUp": the host drains the
|
|
2943
3116
|
// follow-up queue only when the parent run stops calling tools, which is the
|
|
2944
|
-
// hour-long #867 delay.
|
|
2945
|
-
//
|
|
2946
|
-
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 });
|
|
2947
3120
|
assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
|
|
2948
3121
|
const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
|
|
2949
3122
|
assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
|
|
@@ -3002,6 +3175,62 @@ test("completionText names the outcome before the answer", () => {
|
|
|
3002
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');
|
|
3003
3176
|
});
|
|
3004
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
|
+
|
|
3005
3234
|
test("a task-mode child's dialog reaches the host UI and the answer goes back to the child", async () => {
|
|
3006
3235
|
const { pi, tools, fire } = fakePi();
|
|
3007
3236
|
const harness = deps();
|
|
@@ -3648,8 +3877,11 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3648
3877
|
const { ctx, dialogs } = fakeContext();
|
|
3649
3878
|
await h.fire("session_start", ctx);
|
|
3650
3879
|
await eventually(() => callbacks.length === 1, "initial transport callback registration");
|
|
3651
|
-
const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer" }, undefined, undefined, ctx);
|
|
3652
|
-
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
|
+
]);
|
|
3653
3885
|
assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
|
|
3654
3886
|
assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
|
|
3655
3887
|
const original = callbacks[0]!;
|
|
@@ -3661,12 +3893,228 @@ test("session transport selects a peer for outbound delivery and rejects stale c
|
|
|
3661
3893
|
await h.fire("session_shutdown", ctx);
|
|
3662
3894
|
});
|
|
3663
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
|
+
|
|
3664
4112
|
// Issue #867: a completion settling while the parent agent run is active must
|
|
3665
4113
|
// be held by the extension and flushed at the next turn boundary, not parked
|
|
3666
4114
|
// in the host's followUp queue until the whole orchestrator run stops calling
|
|
3667
4115
|
// tools.
|
|
3668
4116
|
test("a background completion settling while the parent agent runs is delivered exactly once at the next turn end", async () => {
|
|
3669
|
-
const { pi, tools, fire, sent } = fakePi();
|
|
4117
|
+
const { pi, tools, fire, sent, userMessages } = fakePi();
|
|
3670
4118
|
const harness = deps();
|
|
3671
4119
|
gentleAgents(pi, {}, harness.deps);
|
|
3672
4120
|
const { ctx } = fakeContext();
|
|
@@ -3691,6 +4139,254 @@ test("a background completion settling while the parent agent runs is delivered
|
|
|
3691
4139
|
assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
3692
4140
|
await fire("turn_end", ctx);
|
|
3693
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");
|
|
3694
4390
|
await fire("session_shutdown", ctx);
|
|
3695
4391
|
});
|
|
3696
4392
|
|
|
@@ -3801,88 +4497,187 @@ test("aborting the caller's signal cancels the subagent, records it, and says wh
|
|
|
3801
4497
|
"a warning names the abort and the cancellation",
|
|
3802
4498
|
);
|
|
3803
4499
|
assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
|
|
4500
|
+
});
|
|
3804
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();
|
|
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);
|
|
3805
4536
|
});
|
|
3806
4537
|
|
|
3807
|
-
test("
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
const hooks = new Map<string, (event: { toolName?: string; systemPrompt?: string; input?: object }, ctx?: { cwd: string }) => { block?: boolean; systemPrompt?: string } | undefined>();
|
|
3814
|
-
const active = names.filter(name => mismatch !== "inactive" || name !== "fetch_content");
|
|
3815
|
-
const registered = names.filter(name => mismatch !== "unregistered" || name !== "fetch_content");
|
|
3816
|
-
const pi = { on: (name: string, hook: typeof hooks extends Map<string, infer H> ? H : never) => hooks.set(name, hook), getActiveTools: () => active,
|
|
3817
|
-
getAllTools: () => registered.map(name => ({ name, sourceInfo: { source: mismatch === "sdk" && name === "fetch_content" ? "sdk" : "extension", path: mismatch === "path" ? "/other.ts" : "/installed/web.ts" } })) };
|
|
3818
|
-
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) });
|
|
3819
|
-
const call = hooks.get("tool_call")!;
|
|
3820
|
-
assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
|
|
3821
|
-
assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
|
|
3822
|
-
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);
|
|
3823
|
-
}
|
|
3824
|
-
});
|
|
3825
|
-
|
|
3826
|
-
for (const condition of ["granted", "declined", "native-denied", "asset-drift"] as const) test(`managed dispatch needs real consent but no attempt command (${condition})`, async () => {
|
|
3827
|
-
const consent = condition === "granted";
|
|
3828
|
-
const h = fakePi(), runtime = deps(), fixtureHome = join(root, `no-attempt-${condition}`);
|
|
3829
|
-
mkdirSync(join(fixtureHome, ".pi/agent/agents"), { recursive: true });
|
|
3830
|
-
writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
3831
|
-
const spawn = runtime.deps.spawn;
|
|
3832
|
-
runtime.deps.spawn = (...args) => { const child = spawn(...args); child.pid = 123; return child; };
|
|
3833
|
-
runtime.deps.process = { platform: "win32", kill() {} };
|
|
3834
|
-
if (condition === "asset-drift") writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), "---\nname: sdd-remediate\ntools: [bash]\n---\nUnowned instructions.");
|
|
3835
|
-
const revision = `sha256:${"a".repeat(64)}`;
|
|
3836
|
-
let attempts = 0, confirmations = 0;
|
|
3837
|
-
const obsolete = async () => { attempts++; throw new Error("Unknown command: sdd-attempt acquire/settle"); };
|
|
3838
|
-
const nativeSdd = {
|
|
3839
|
-
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 } }),
|
|
3840
|
-
sddAttemptAcquire: obsolete, sddAttemptSettle: obsolete,
|
|
3841
|
-
} as unknown as NativeReviewCli;
|
|
3842
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
|
|
3843
|
-
const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return consent; });
|
|
3844
|
-
await h.fire("session_start", ctx);
|
|
3845
|
-
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);
|
|
3846
|
-
let result: Awaited<typeof launch> | undefined;
|
|
3847
|
-
if (consent) result = await launch;
|
|
3848
|
-
else await assert.rejects(launch, condition === "native-denied" ? /Stale remediation selection/ : condition === "asset-drift" ? /Unsupported remediation actor content/ : /fresh human authorization/);
|
|
3849
|
-
await tick();
|
|
3850
|
-
assert.equal(confirmations, ["native-denied", "asset-drift"].includes(condition) ? 0 : 1);
|
|
3851
|
-
assert.equal(runtime.spawned.length, consent ? 1 : 0);
|
|
3852
|
-
assert.equal(attempts, 0);
|
|
3853
|
-
assert.equal(h.tools.has("subagent_reconcile"), false);
|
|
3854
|
-
if (consent) {
|
|
3855
|
-
assert.ok(runtime.children[0].written.some((command) => command.type === "prompt"), "the registered actor receives its prompt");
|
|
3856
|
-
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);
|
|
3857
|
-
await assert.rejects(repeat, /Remediation already queued or running/);
|
|
3858
|
-
assert.equal(confirmations, 2, "independent human consent does not permit overlapping managed actors");
|
|
3859
|
-
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);
|
|
3860
4544
|
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
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
|
+
}
|
|
3867
4682
|
}
|
|
3868
|
-
await h.fire("session_shutdown", ctx);
|
|
3869
|
-
assert.equal(attempts, 0, "terminal cleanup must not invoke settlement");
|
|
3870
|
-
});
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
test("registered task actor receives ordinary checkbox and configured TDD guidance", async () => {
|
|
3874
|
-
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
3875
|
-
const home = join(root, "task-truth-home");
|
|
3876
|
-
mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
|
|
3877
|
-
writeFileSync(join(home, ".pi/agent/agents/sdd-tasks.md"), readFileSync("assets/agents/sdd-tasks.md"));
|
|
3878
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home });
|
|
3879
|
-
await h.fire("session_start", ctx);
|
|
3880
|
-
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);
|
|
3881
|
-
await tick();
|
|
3882
|
-
assert.equal(runtime.spawned.length, 1);
|
|
3883
|
-
const args = runtime.spawned[0], instructions = args[args.indexOf("--append-system-prompt") + 1];
|
|
3884
|
-
assert.match(instructions, /Only when configured strict TDD is active/);
|
|
3885
|
-
assert.match(instructions, /- \[ \] 1\. Implement and verify the behavior\./);
|
|
3886
|
-
assert.doesNotMatch(instructions, /<!-- sdd-owner:/);
|
|
3887
|
-
await h.fire("session_shutdown", ctx);
|
|
3888
4683
|
});
|