gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -1,29 +1,48 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
4
5
|
import { join, relative, resolve } from "node:path";
|
|
6
|
+
import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
7
|
+
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
|
|
5
8
|
import test, { after, mock } from "node:test";
|
|
6
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
10
|
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
8
11
|
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, legacySubagentsInstalled, type AgentsDeps } from "../extensions/gentle-agents.ts";
|
|
9
12
|
import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
|
|
13
|
+
import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
|
|
10
14
|
import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
11
15
|
import { NativePointerScope } from "../lib/native-pointer-region.ts";
|
|
16
|
+
import { PresenceCursor, PresencePublisher, listPresence, readActivity } from "../lib/orchestrator-presence.ts";
|
|
12
17
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
13
18
|
import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
|
|
19
|
+
import { AgentRunner } from "../lib/agents-runner.ts";
|
|
20
|
+
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
21
|
+
import { CHILD_METRICS_EVENT } from "../lib/runtime-metrics-children.ts";
|
|
22
|
+
import { renderSddPreflightPrompt } from "../lib/sdd-preflight.ts";
|
|
14
23
|
|
|
15
24
|
// Gentle Agents extension: the subagent_* tools drive isolated pi children,
|
|
16
25
|
// the card above the editor follows the store, and dialogs reach the host UI.
|
|
17
26
|
|
|
18
27
|
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
19
28
|
interface Registered {
|
|
29
|
+
parameters: { properties: Record<string, unknown> };
|
|
30
|
+
renderShell?: string;
|
|
20
31
|
name: string;
|
|
21
|
-
execute(id: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
|
|
32
|
+
execute(id: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ text: string }>; details: Record<string, unknown> }>;
|
|
22
33
|
renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
const plainTheme = { fg: (_color: string, text: string) => text };
|
|
26
37
|
const fakeTui = { requestRender() {} };
|
|
38
|
+
const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
|
|
39
|
+
executionMode: "auto",
|
|
40
|
+
artifactStore: "openspec",
|
|
41
|
+
chainedPrStrategy: "ask-on-risk",
|
|
42
|
+
reviewBudgetLines: 400,
|
|
43
|
+
engramAvailable: false,
|
|
44
|
+
prompted: true,
|
|
45
|
+
});
|
|
27
46
|
|
|
28
47
|
type Overlay = {
|
|
29
48
|
render(width: number): string[];
|
|
@@ -45,8 +64,10 @@ const root = mkdtempSync(join(tmpdir(), "gentle-agents-ext-"));
|
|
|
45
64
|
after(() => rmSync(root, { recursive: true, force: true }));
|
|
46
65
|
const home = join(root, "home");
|
|
47
66
|
const cwd = join(root, "project");
|
|
67
|
+
const nonGitCwd = join(root, "non-git-project");
|
|
48
68
|
mkdirSync(join(home, ".pi", "agent", "agents"), { recursive: true });
|
|
49
69
|
mkdirSync(cwd, { recursive: true });
|
|
70
|
+
mkdirSync(nonGitCwd, { recursive: true });
|
|
50
71
|
writeFileSync(join(home, ".pi", "agent", "agents", "explore.md"), "---\ndescription: maps things\nmodel: openai-codex/gpt-5.6-terra\nthinking: high\ntools: [read, grep]\n---\nYou map things.");
|
|
51
72
|
writeFileSync(join(home, ".pi", "agent", "subagents.json"), JSON.stringify({ max_concurrency: 2, model_profiles: { explore: { effort: "low" } } }));
|
|
52
73
|
|
|
@@ -57,9 +78,22 @@ function fakePi() {
|
|
|
57
78
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionContext): Promise<void> }>();
|
|
58
79
|
const sent: Array<{ message: Record<string, unknown>; options: Record<string, unknown> }> = [];
|
|
59
80
|
const renderers = new Map<string, (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
|
|
81
|
+
const entryRenderers = new Map<string, (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }>();
|
|
82
|
+
const entries: Array<{ type: string; customType: string; data: unknown }> = [];
|
|
83
|
+
const events: Array<{ name: string; data: unknown }> = [];
|
|
84
|
+
const listeners = new Map<string, Set<(data: unknown) => void>>();
|
|
60
85
|
const pi = {
|
|
86
|
+
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
|
87
|
+
events: {
|
|
88
|
+
emit: (name: string, data: unknown) => { events.push({ name, data }); for (const listener of listeners.get(name) ?? []) listener(data); },
|
|
89
|
+
on: (name: string, listener: (data: unknown) => void) => {
|
|
90
|
+
const set = listeners.get(name) ?? new Set(); listeners.set(name, set); set.add(listener);
|
|
91
|
+
return () => { set.delete(listener); };
|
|
92
|
+
},
|
|
93
|
+
},
|
|
61
94
|
sendMessage: (message: Record<string, unknown>, options: Record<string, unknown>) => sent.push({ message, options }),
|
|
62
95
|
registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer),
|
|
96
|
+
registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer),
|
|
63
97
|
on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]),
|
|
64
98
|
registerTool: (tool: Registered) => tools.set(tool.name, tool),
|
|
65
99
|
registerShortcut: (key: string, registration: { description: string; handler(ctx: ExtensionContext): Promise<void> }) => shortcuts.set(key, registration),
|
|
@@ -68,26 +102,30 @@ function fakePi() {
|
|
|
68
102
|
const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
|
|
69
103
|
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
|
|
70
104
|
};
|
|
71
|
-
return { pi, tools, shortcuts, commands, fire, sent, renderers };
|
|
105
|
+
return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
|
|
72
106
|
}
|
|
73
107
|
|
|
74
|
-
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) {
|
|
108
|
+
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() {} }) {
|
|
75
109
|
const widgets = new Map<string, (tui: unknown, theme: unknown) => { render(width: number): string[] }>();
|
|
76
110
|
const dialogs: string[] = [];
|
|
77
111
|
const overlays: Overlay[] = [];
|
|
78
112
|
const customCompletions: unknown[] = [];
|
|
113
|
+
const customOptions: unknown[] = [];
|
|
79
114
|
const ctx = {
|
|
115
|
+
cwd,
|
|
80
116
|
hasUI: true,
|
|
81
|
-
|
|
117
|
+
mode: "tui",
|
|
118
|
+
sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [] },
|
|
82
119
|
ui: {
|
|
83
120
|
notify: (message: string) => dialogs.push(`notify:${message}`),
|
|
84
|
-
custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay) =>
|
|
121
|
+
custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) =>
|
|
85
122
|
new Promise((resolve) => {
|
|
123
|
+
customOptions.push(options);
|
|
86
124
|
const done = (value: unknown) => {
|
|
87
125
|
customCompletions.push(value);
|
|
88
126
|
resolve(value);
|
|
89
127
|
};
|
|
90
|
-
const component = factory(
|
|
128
|
+
const component = factory(overlayTui, plainTheme, {}, done);
|
|
91
129
|
overlays.push(component);
|
|
92
130
|
}),
|
|
93
131
|
setWidget(key: string, content: ((tui: unknown, theme: unknown) => { render(width: number): string[] }) | undefined) {
|
|
@@ -113,7 +151,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
|
|
|
113
151
|
const factory = widgets.get("gentle-agents");
|
|
114
152
|
return factory ? factory(tui, plainTheme).render(72).map(stripAnsi) : undefined;
|
|
115
153
|
};
|
|
116
|
-
return { ctx, widget, dialogs, overlays, customCompletions };
|
|
154
|
+
return { ctx, widget, dialogs, overlays, customCompletions, customOptions };
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: string[][] } {
|
|
@@ -124,6 +162,7 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
124
162
|
children,
|
|
125
163
|
spawned,
|
|
126
164
|
deps: {
|
|
165
|
+
runtimeMetricsPolicy: { resolve: () => { throw new Error("Policy not configured in fixture"); } },
|
|
127
166
|
spawn: (command, args) => {
|
|
128
167
|
spawned.push([command, ...args]);
|
|
129
168
|
const child = fakeChild();
|
|
@@ -134,13 +173,992 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
134
173
|
schedule: () => () => {},
|
|
135
174
|
pi: { command: "pi", args: [] },
|
|
136
175
|
home,
|
|
176
|
+
resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
|
|
137
177
|
env: { PATH: "/bin" },
|
|
138
178
|
},
|
|
139
179
|
};
|
|
140
180
|
}
|
|
141
181
|
|
|
182
|
+
test("all nine subagent registrations own their transcript shell", () => {
|
|
183
|
+
const { pi, tools } = fakePi();
|
|
184
|
+
gentleAgents(pi, {}, deps().deps);
|
|
185
|
+
assert.equal(tools.size, 9);
|
|
186
|
+
for (const tool of tools.values()) assert.equal(tool.renderShell, "self", tool.name);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("host query delivery exposes correlation and accepts one current-session reply", async () => {
|
|
190
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
191
|
+
const harness = deps();
|
|
192
|
+
gentleAgents(pi, {}, harness.deps);
|
|
193
|
+
const { ctx } = fakeContext();
|
|
194
|
+
await fire("session_start", ctx);
|
|
195
|
+
const started = await tools.get("subagent_run")!.execute("query", { agent: "explore", task: "Ask once", mode: "background" }, undefined, undefined, ctx);
|
|
196
|
+
const taskId = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
197
|
+
await tick();
|
|
198
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Which file?" });
|
|
199
|
+
await tick();
|
|
200
|
+
assert.match(String(sent.at(-1)?.message.content), new RegExp(`Task ID: ${taskId}\\nRequest ID: q1`));
|
|
201
|
+
assert.equal(sent.at(-1)?.message.display, true, "an explicit child query remains visible");
|
|
202
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
|
|
203
|
+
assert.match((await tools.get("subagent_reply")!.execute("stale", { task_id: taskId, request_id: "q1", message: "wrong" }, undefined, undefined, ctx)).content[0].text, /unavailable/);
|
|
204
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s1";
|
|
205
|
+
assert.equal((await tools.get("subagent_reply")!.execute("reply", { task_id: taskId, request_id: "q1", message: "src/a.ts" }, undefined, undefined, ctx)).content[0].text, "Reply accepted for delivery.");
|
|
206
|
+
assert.deepEqual(harness.children[0].sent.at(-1), { id: "q1", kind: "reply", message: "src/a.ts" });
|
|
207
|
+
assert.match((await tools.get("subagent_reply")!.execute("duplicate", { task_id: taskId, request_id: "q1", message: "again" }, undefined, undefined, ctx)).content[0].text, /unavailable/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("first foreground query yields while its child runs and delivers one completion", async () => {
|
|
211
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
212
|
+
const harness = deps();
|
|
213
|
+
gentleAgents(pi, {}, harness.deps);
|
|
214
|
+
const { ctx } = fakeContext();
|
|
215
|
+
await fire("session_start", ctx);
|
|
216
|
+
const pending = tools.get("subagent_run")!.execute("foreground", { agent: "explore", task: "Ask then finish" }, undefined, undefined, ctx);
|
|
217
|
+
await tick();
|
|
218
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "Which file?" });
|
|
219
|
+
const yielded = await pending;
|
|
220
|
+
const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
|
|
221
|
+
assert.equal((yielded as { terminate?: boolean }).terminate, true);
|
|
222
|
+
assert.deepEqual(harness.children[0].killed, []);
|
|
223
|
+
await tools.get("subagent_reply")!.execute("reply", { task_id: taskId, request_id: "q1", message: "src/a.ts" }, undefined, undefined, ctx);
|
|
224
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
|
|
225
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
226
|
+
await tick();
|
|
227
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("cancelling a yielded foreground task prevents completion follow-up", async () => {
|
|
231
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
232
|
+
const harness = deps();
|
|
233
|
+
gentleAgents(pi, {}, harness.deps);
|
|
234
|
+
const { ctx } = fakeContext();
|
|
235
|
+
await fire("session_start", ctx);
|
|
236
|
+
const pending = tools.get("subagent_run")!.execute("cancel", { agent: "explore", task: "cancel after query" }, undefined, undefined, ctx);
|
|
237
|
+
await tick();
|
|
238
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "q" });
|
|
239
|
+
const yielded = await pending;
|
|
240
|
+
const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
|
|
241
|
+
assert.match((await tools.get("subagent_cancel")!.execute("stop", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /Cancelled task/);
|
|
242
|
+
await tick();
|
|
243
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "late" }], stopReason: "stop" }] });
|
|
244
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
245
|
+
await tick();
|
|
246
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("yielded foreground completion is suppressed after session replacement or cancellation", async () => {
|
|
250
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
251
|
+
const harness = deps();
|
|
252
|
+
gentleAgents(pi, {}, harness.deps);
|
|
253
|
+
const { ctx } = fakeContext();
|
|
254
|
+
await fire("session_start", ctx);
|
|
255
|
+
const pending = tools.get("subagent_run")!.execute("switch", { agent: "explore", task: "switch session" }, undefined, undefined, ctx);
|
|
256
|
+
await tick();
|
|
257
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "q" });
|
|
258
|
+
const yielded = await pending;
|
|
259
|
+
const taskId = (yielded.details.gentleAgents as { taskId: string }).taskId;
|
|
260
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
|
|
261
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
|
|
262
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
263
|
+
await tick();
|
|
264
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
265
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s1";
|
|
266
|
+
assert.match((await tools.get("subagent_cancel")!.execute("cancel", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /not running/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("first handoff failure keeps ordinary completion, later failure retains yielded completion", async () => {
|
|
270
|
+
const first = fakePi();
|
|
271
|
+
const firstHarness = deps();
|
|
272
|
+
gentleAgents(first.pi, {}, firstHarness.deps);
|
|
273
|
+
const firstContext = fakeContext();
|
|
274
|
+
await first.fire("session_start", firstContext.ctx);
|
|
275
|
+
(first.pi as unknown as { sendMessage(): void }).sendMessage = () => { throw new Error("host unavailable"); };
|
|
276
|
+
const ordinary = first.tools.get("subagent_run")!.execute("first", { agent: "explore", task: "fail handoff" }, undefined, undefined, firstContext.ctx);
|
|
277
|
+
await tick();
|
|
278
|
+
firstHarness.children[0].message({ id: "q1", kind: "query", message: "q" });
|
|
279
|
+
firstHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "ordinary" }], stopReason: "stop" }] });
|
|
280
|
+
firstHarness.children[0].emit({ type: "agent_settled" });
|
|
281
|
+
assert.equal((await ordinary).content[0].text, "ordinary");
|
|
282
|
+
|
|
283
|
+
const second = fakePi();
|
|
284
|
+
const secondHarness = deps();
|
|
285
|
+
gentleAgents(second.pi, {}, secondHarness.deps);
|
|
286
|
+
const secondContext = fakeContext();
|
|
287
|
+
await second.fire("session_start", secondContext.ctx);
|
|
288
|
+
let sends = 0;
|
|
289
|
+
(second.pi as unknown as { sendMessage(message: Record<string, unknown>, options: Record<string, unknown>): void }).sendMessage = (message, options) => {
|
|
290
|
+
sends += 1;
|
|
291
|
+
if (sends === 2) throw new Error("second unavailable");
|
|
292
|
+
second.sent.push({ message, options });
|
|
293
|
+
};
|
|
294
|
+
const pending = second.tools.get("subagent_run")!.execute("second", { agent: "explore", task: "two queries" }, undefined, undefined, secondContext.ctx);
|
|
295
|
+
await tick();
|
|
296
|
+
secondHarness.children[0].message({ id: "q1", kind: "query", message: "first" });
|
|
297
|
+
await pending;
|
|
298
|
+
secondHarness.children[0].message({ id: "q2", kind: "query", message: "second" });
|
|
299
|
+
secondHarness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
|
|
300
|
+
secondHarness.children[0].emit({ type: "agent_settled" });
|
|
301
|
+
await tick();
|
|
302
|
+
assert.equal(second.sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("foreground handoff survives settlement before its original await resumes", async () => {
|
|
306
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
307
|
+
const harness = deps();
|
|
308
|
+
gentleAgents(pi, {}, harness.deps);
|
|
309
|
+
const { ctx } = fakeContext();
|
|
310
|
+
await fire("session_start", ctx);
|
|
311
|
+
const pending = tools.get("subagent_run")!.execute("race", { agent: "explore", task: "query then settle" }, undefined, undefined, ctx);
|
|
312
|
+
await tick();
|
|
313
|
+
harness.children[0].message({ id: "q1", kind: "query", message: "q" });
|
|
314
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "race result" }], stopReason: "stop" }] });
|
|
315
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
316
|
+
assert.equal((await pending as { terminate?: boolean }).terminate, true);
|
|
317
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1);
|
|
318
|
+
});
|
|
319
|
+
|
|
142
320
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
143
321
|
|
|
322
|
+
test("child parent-message tooling admits notifications and the active parent preserves raw model text", async () => {
|
|
323
|
+
const child = fakePi();
|
|
324
|
+
const listeners = new Map<string, Array<(value: Record<string, unknown>) => void>>();
|
|
325
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
326
|
+
gentleAgents(child.pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: "fixture" }, {
|
|
327
|
+
childIpc: {
|
|
328
|
+
send: (frame: Record<string, unknown>) => { frames.push(frame); return true; },
|
|
329
|
+
on: (event: string, listener: (value: Record<string, unknown>) => void) => listeners.set(event, [...(listeners.get(event) ?? []), listener]),
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
assert.deepEqual([...child.tools.keys()], ["subagent_parent_message"]);
|
|
333
|
+
const pending = child.tools.get("subagent_parent_message")!.execute("message", { message: "raw\u001B[2J text" }, undefined, undefined, {} as ExtensionContext);
|
|
334
|
+
assert.deepEqual(frames, [{ id: "n1", kind: "notification", message: "raw\u001B[2J text" }]);
|
|
335
|
+
for (const listener of listeners.get("message") ?? []) listener({ id: "n1", kind: "ack", accepted: true });
|
|
336
|
+
assert.equal((await pending).content[0].text, "Notification accepted by the parent.");
|
|
337
|
+
|
|
338
|
+
const parent = fakePi();
|
|
339
|
+
const runtime = deps();
|
|
340
|
+
gentleAgents(parent.pi, {}, runtime.deps);
|
|
341
|
+
const { ctx } = fakeContext();
|
|
342
|
+
await parent.fire("session_start", ctx);
|
|
343
|
+
await parent.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "notify", mode: "background" }, undefined, undefined, ctx);
|
|
344
|
+
await tick();
|
|
345
|
+
runtime.children[0].message({ id: "n1", kind: "notification", message: "raw\u001B[2J text" });
|
|
346
|
+
await tick();
|
|
347
|
+
assert.equal(parent.sent[0]?.message.content, "raw\u001B[2J text");
|
|
348
|
+
assert.equal(parent.sent[0]?.message.display, false, "ordinary child notifications remain model-visible but do not render in the transcript");
|
|
349
|
+
assert.deepEqual(parent.sent[0]?.options, { deliverAs: "followUp", triggerTurn: true });
|
|
350
|
+
const rendered = parent.renderers.get("gentle-agents.message")!(parent.sent[0]?.message, { expanded: true }, plainTheme).render(80).join("\n");
|
|
351
|
+
assert.match(rendered, /raw\\x1B\[2J text/);
|
|
352
|
+
(ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
|
|
353
|
+
await parent.fire("session_start", ctx, { type: "session_start", reason: "new" });
|
|
354
|
+
runtime.children[0].message({ id: "n2", kind: "notification", message: "must not reach a replacement session" });
|
|
355
|
+
await tick();
|
|
356
|
+
assert.equal(parent.sent.length, 1, "a child from the prior session delivers no notification after session replacement");
|
|
357
|
+
assert.deepEqual(runtime.children[0].sent, [{ id: "n1", kind: "ack", accepted: true }, { id: "n2", kind: "ack", accepted: false, error: "task parent is not the active host session" }]);
|
|
358
|
+
await parent.fire("session_shutdown", ctx);
|
|
359
|
+
});
|
|
360
|
+
for (const boundary of ["allowed", "env", "session", "replacement", "bus-throws", "no-spawn", "long-running"] as const) {
|
|
361
|
+
test(`local child composition through real extensions and RPC runner: ${boundary}`, async (t) => {
|
|
362
|
+
const discarded: number[] = [];
|
|
363
|
+
const discard = AgentRunner.prototype.discardResponseObservations;
|
|
364
|
+
t.mock.method(AgentRunner.prototype, "discardResponseObservations", function (this: AgentRunner, id: string) {
|
|
365
|
+
const live = Reflect.get(this, "live").get(id);
|
|
366
|
+
discarded.push(live?.observations?.responses.length ?? 0);
|
|
367
|
+
discard.call(this, id);
|
|
368
|
+
assert.equal(live?.observations, undefined, "buffer gone synchronously, without another RPC");
|
|
369
|
+
});
|
|
370
|
+
const h = fakePi();
|
|
371
|
+
const runtime = deps();
|
|
372
|
+
let catalogAttempts = 0;
|
|
373
|
+
if (boundary === "allowed") runtime.deps.lookupPiCatalogName = () => {
|
|
374
|
+
catalogAttempts++;
|
|
375
|
+
return new Promise(() => {});
|
|
376
|
+
};
|
|
377
|
+
const context = fakeContext();
|
|
378
|
+
const spawn = runtime.deps.spawn!;
|
|
379
|
+
runtime.deps.spawn = (...args) => {
|
|
380
|
+
const child = spawn(...args);
|
|
381
|
+
const on = child.on.bind(child);
|
|
382
|
+
child.on = ((event: string, listener: (...args: any[]) => void) => {
|
|
383
|
+
if (event === "spawn" && boundary !== "no-spawn") queueMicrotask(() => listener());
|
|
384
|
+
return on(event as any, listener);
|
|
385
|
+
}) as typeof child.on;
|
|
386
|
+
return child;
|
|
387
|
+
};
|
|
388
|
+
let clock = 1;
|
|
389
|
+
const renewalTimers = new Map<number, () => void>();
|
|
390
|
+
const metricsSchedule = (fn: () => void, ms: number) => {
|
|
391
|
+
const at = clock + ms; renewalTimers.set(at, fn); return () => { renewalTimers.delete(at); };
|
|
392
|
+
};
|
|
393
|
+
let calls = 0;
|
|
394
|
+
const policy = { resolve: () => "fixture", exec: async () => {
|
|
395
|
+
calls++;
|
|
396
|
+
return { stdout: JSON.stringify({ schema: "gentle-ai.telemetry-policy/v1", operation: "policy", enabled: true,
|
|
397
|
+
source: "state", reason: "enabled" }), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
398
|
+
} };
|
|
399
|
+
const env: NodeJS.ProcessEnv = {};
|
|
400
|
+
const profile = join(root, `metrics-${boundary}`);
|
|
401
|
+
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
402
|
+
writeFileSync(join(profile, "agents", "gentle-ai-worker.md"), readFileSync(new URL("../assets/agents/gentle-ai-worker.md", import.meta.url)));
|
|
403
|
+
writeFileSync(join(profile, "subagents.json"), JSON.stringify({ model_profiles: { "gentle-ai-worker": { model: "openai/gpt-4o", effort: "high" } } }));
|
|
404
|
+
gentleAgents(h.pi, env, { ...runtime.deps, env, agentHome: profile, runtimeMetricsPolicy: policy, metricsNow: () => clock, metricsSchedule });
|
|
405
|
+
const listenerCounts = () => [...h.listeners].map(([name, set]) => [name, set.size]);
|
|
406
|
+
const initialListeners = listenerCounts();
|
|
407
|
+
await h.fire("session_start", context.ctx);
|
|
408
|
+
const result = h.tools.get("subagent_run")!.execute("call", { agent: "gentle-ai-worker", task: "private task", mode: "task" }, undefined, undefined, context.ctx);
|
|
409
|
+
await tick();
|
|
410
|
+
assert.equal(runtime.children.length, 1);
|
|
411
|
+
if (boundary === "allowed") assert.equal(catalogAttempts, 1, "child launch does not await catalog loading");
|
|
412
|
+
const child = runtime.children[0];
|
|
413
|
+
const launchedCalls = calls;
|
|
414
|
+
for (let i = 0; i < 10; i++) child.emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "private streamed text" } });
|
|
415
|
+
assert.equal(calls, launchedCalls, "no per-chunk policy process");
|
|
416
|
+
if (boundary === "long-running") {
|
|
417
|
+
for (let i = 0; i < 6; i++) {
|
|
418
|
+
clock += 20_000;
|
|
419
|
+
for (const [at, fn] of [...renewalTimers]) if (at <= clock) { renewalTimers.delete(at); fn(); }
|
|
420
|
+
await tick();
|
|
421
|
+
child.emit({ type: "message_end", message: { role: "assistant", model: "gpt-4o", provider: "openai",
|
|
422
|
+
providerThinkingLevel: "low", stopReason: "stop", usage: { input: 7, output: 3 } } });
|
|
423
|
+
}
|
|
424
|
+
assert.equal(calls, launchedCalls, "no telemetry policy renewal during a two-minute child");
|
|
425
|
+
assert.equal(renewalTimers.size, 0, "child telemetry never schedules a lease timer");
|
|
426
|
+
}
|
|
427
|
+
if (boundary === "env") env.DO_NOT_TRACK = "yes";
|
|
428
|
+
if (boundary === "session" || boundary === "replacement") {
|
|
429
|
+
child.emit({ type: "message_end", message: { role: "assistant", model: "gpt-4o", provider: "openai",
|
|
430
|
+
stopReason: "stop", usage: { input: 7, output: 3 } } });
|
|
431
|
+
if (boundary === "replacement") {
|
|
432
|
+
Object.assign(context.ctx.sessionManager, { getSessionId: () => "replacement" });
|
|
433
|
+
await h.fire("session_start", context.ctx);
|
|
434
|
+
} else await h.fire("session_shutdown", context.ctx);
|
|
435
|
+
assert.deepEqual(discarded, [1], "idle buffered response discarded at lifecycle boundary");
|
|
436
|
+
assert.equal(renewalTimers.size, 0);
|
|
437
|
+
if (boundary === "session") {
|
|
438
|
+
assert.ok([...h.listeners.values()].every(set => set.size === 0), "old bus subscriptions removed");
|
|
439
|
+
const fresh = fakePi();
|
|
440
|
+
Object.assign(fresh.pi, { events: h.pi.events });
|
|
441
|
+
gentleAgents(fresh.pi, env, { ...runtime.deps, env, runtimeMetricsPolicy: policy, metricsSchedule });
|
|
442
|
+
await fresh.fire("session_start", context.ctx);
|
|
443
|
+
assert.deepEqual(listenerCounts(), initialListeners, "fresh instance installs one subscription set");
|
|
444
|
+
await fresh.fire("session_shutdown", context.ctx);
|
|
445
|
+
assert.ok([...h.listeners.values()].every(set => set.size === 0));
|
|
446
|
+
assert.equal(renewalTimers.size, 0);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (boundary === "bus-throws") h.pi.events.on(CHILD_METRICS_EVENT, () => { throw new Error("private bus error"); });
|
|
450
|
+
for (const model of ["gpt-4o", "gpt-4o-mini"]) child.emit({ type: "message_end", message: {
|
|
451
|
+
role: "assistant", model, provider: "openai", providerThinkingLevel: "low", stopReason: "stop", usage: { input: 7, output: 3 } } });
|
|
452
|
+
child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
453
|
+
child.emit({ type: "agent_settled" });
|
|
454
|
+
assert.match((await result).content[0].text, boundary === "session" ? /cancelled/ : /done/);
|
|
455
|
+
await tick(); await tick();
|
|
456
|
+
const events = h.events.filter(event => event.name === CHILD_METRICS_EVENT);
|
|
457
|
+
const admitted = boundary === "allowed" || boundary === "bus-throws" || boundary === "long-running";
|
|
458
|
+
assert.equal(events.length, Number(admitted));
|
|
459
|
+
if (admitted) {
|
|
460
|
+
assert.ok(!JSON.stringify(events).includes("private"));
|
|
461
|
+
const event = events[0].data as import("../lib/runtime-metrics-children.ts").ChildMetricsEvent;
|
|
462
|
+
assert.equal(event.launch.agentClass, "worker");
|
|
463
|
+
assert.equal(event.launch.selectedEffort, "high");
|
|
464
|
+
assert.equal(event.responses.length, boundary === "long-running" ? 8 : 2);
|
|
465
|
+
assert.ok(event.responses.every(row => row.agentClass === "worker" && row.effort === "high"
|
|
466
|
+
&& row.selectedProvider === "openai" && row.selectedModelId === "gpt-4o"));
|
|
467
|
+
assert.equal(event.agentSettled, true);
|
|
468
|
+
child.emit({ type: "agent_settled" });
|
|
469
|
+
await tick();
|
|
470
|
+
assert.equal(h.events.filter(event => event.name === CHILD_METRICS_EVENT).length, 1, "completion consumed once, even after bus failure");
|
|
471
|
+
}
|
|
472
|
+
assert.equal(calls, 0, "child telemetry performs no launch, renewal or pending-forward policy query");
|
|
473
|
+
assert.equal(renewalTimers.size, 0, "no renewal timer after the last task finishes");
|
|
474
|
+
assert.ok(!JSON.stringify(h.entries).includes("launch_configuration"));
|
|
475
|
+
await h.fire("session_shutdown", context.ctx);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function eventually(check: () => boolean, message: string): Promise<void> {
|
|
480
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
481
|
+
if (check()) return;
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
483
|
+
}
|
|
484
|
+
assert.fail(message);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function liveProfile(name: string): string {
|
|
488
|
+
const profile = join(realpathSync(root), name);
|
|
489
|
+
mkdirSync(join(profile, "agents"), { recursive: true });
|
|
490
|
+
for (const agent of ["local", "peer"]) {
|
|
491
|
+
writeFileSync(join(profile, "agents", `${agent}.md`), `---\ndescription: ${agent}\n---\nFixture agent.`);
|
|
492
|
+
}
|
|
493
|
+
return profile;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function liveInstance(t: test.TestContext, profile: string, sessionId: string) {
|
|
497
|
+
const h = fakePi();
|
|
498
|
+
const runtime = deps();
|
|
499
|
+
const context = fakeContext();
|
|
500
|
+
Object.assign(context.ctx.sessionManager, {
|
|
501
|
+
getSessionId: () => sessionId,
|
|
502
|
+
getSessionName: () => sessionId,
|
|
503
|
+
getCwd: () => join(realpathSync(root), sessionId),
|
|
504
|
+
});
|
|
505
|
+
runtime.deps.schedule = (fn, ms) => {
|
|
506
|
+
const timer = setTimeout(fn, ms);
|
|
507
|
+
timer.unref();
|
|
508
|
+
return () => clearTimeout(timer);
|
|
509
|
+
};
|
|
510
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, agentHome: profile });
|
|
511
|
+
t.after(async () => {
|
|
512
|
+
for (const overlay of context.overlays) overlay.handleInput("q");
|
|
513
|
+
await h.fire("session_shutdown", context.ctx, { reason: "quit" });
|
|
514
|
+
});
|
|
515
|
+
return { ...h, ...context, ...runtime };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function liveOverlay(instance: ReturnType<typeof liveInstance>) {
|
|
519
|
+
const opened = instance.commands.get("gentle:agents")!.handler("", instance.ctx);
|
|
520
|
+
await eventually(() => instance.overlays.length > 0, "overlay must mount without waiting for an unbounded directory scan");
|
|
521
|
+
const overlay = instance.overlays.at(-1)!;
|
|
522
|
+
const frame = () => overlay.render(160).map(stripAnsi).join("\n");
|
|
523
|
+
return { overlay, frame, opened };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
test("live-only extension instances discover same-profile peers across cwd boundaries without importing their tasks", async (t) => {
|
|
527
|
+
const profile = liveProfile("live-peer-profile");
|
|
528
|
+
const local = liveInstance(t, profile, "local-live");
|
|
529
|
+
const peer = liveInstance(t, profile, "peer-live");
|
|
530
|
+
assert.equal(listPresence(profile).entries.length, 0, "factory construction starts no presence resources");
|
|
531
|
+
await local.fire("session_start", local.ctx, { reason: "startup" });
|
|
532
|
+
await peer.fire("session_start", peer.ctx, { reason: "startup" });
|
|
533
|
+
assert.equal(listPresence(profile).entries.length, 2, "idle open orchestrators publish empty activity");
|
|
534
|
+
for (const header of listPresence(profile).entries) assert.deepEqual(readActivity(profile, header).activity?.tasks, []);
|
|
535
|
+
const panel = await liveOverlay(local);
|
|
536
|
+
panel.overlay.handleInput("a");
|
|
537
|
+
await eventually(() => /peer-live/.test(panel.frame()), "an idle peer with zero children must be discoverable");
|
|
538
|
+
const run = async (instance: typeof local, agent: string) => {
|
|
539
|
+
const result = await instance.tools.get("subagent_run")!.execute(agent, { agent, task: agent, mode: "background" }, undefined, undefined, instance.ctx);
|
|
540
|
+
return (result.details.gentleAgents as { taskId: string }).taskId;
|
|
541
|
+
};
|
|
542
|
+
await run(local, "local");
|
|
543
|
+
const peerId = await run(peer, "peer");
|
|
544
|
+
await tick();
|
|
545
|
+
peer.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "peer streamed text" } });
|
|
546
|
+
await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.summary.id === peerId && row.thread.items.some((item) => item.text === "peer streamed text"))), "task deltas, not just status changes, must publish peer activity");
|
|
547
|
+
await eventually(() => /Subagent peer/.test(panel.frame()), "open directory refreshes peer children without reopening");
|
|
548
|
+
const peerPanel = await liveOverlay(peer);
|
|
549
|
+
peerPanel.overlay.handleInput("a");
|
|
550
|
+
await eventually(() => /Subagent local/.test(peerPanel.frame()), "discovery is symmetric across extension instances");
|
|
551
|
+
const lines = panel.overlay.render(160).map(stripAnsi);
|
|
552
|
+
const y = lines.findIndex((line) => line.includes("Subagent peer"));
|
|
553
|
+
assert.ok(y > 0);
|
|
554
|
+
panel.overlay.handleMouse?.(mouse("click", "left", 4, y, 160, lines.length));
|
|
555
|
+
assert.match(panel.frame(), /peer streamed text/, "remote inspection reads the peer's pinned activity, not a local thread");
|
|
556
|
+
for (const key of ["s", "c", "o", "\r"]) panel.overlay.handleInput(key);
|
|
557
|
+
assert.deepEqual(local.customCompletions, [], "remote rows never route Open into the local editor");
|
|
558
|
+
assert.deepEqual(local.children[0].killed, [], "remote Stop never cancels the local child");
|
|
559
|
+
assert.deepEqual(peer.children[0].killed, [], "peer rows provide no remote control channel");
|
|
560
|
+
assert.doesNotMatch((await local.tools.get("subagent_list_tasks")!.execute("list", {}, undefined, undefined, local.ctx)).content[0].text, /· peer ·/);
|
|
561
|
+
assert.match((await local.tools.get("subagent_status")!.execute("status", { task_id: peerId }, undefined, undefined, local.ctx)).content[0].text, /no task/, "peer discovery must never restore into local TaskStore");
|
|
562
|
+
panel.overlay.handleInput("a");
|
|
563
|
+
assert.doesNotMatch(panel.frame(), /Subagent peer|peer-live|Current orchestrator/);
|
|
564
|
+
assert.match(panel.frame(), /Subagent local/);
|
|
565
|
+
panel.overlay.handleInput("a");
|
|
566
|
+
await peer.fire("session_shutdown", peer.ctx, { reason: "resume" });
|
|
567
|
+
await eventually(() => !/peer-live|Subagent peer/.test(panel.frame()), "shutdown removes the peer from an already-open directory");
|
|
568
|
+
assert.equal(listPresence(profile).entries.length, 1);
|
|
569
|
+
panel.overlay.handleInput("q");
|
|
570
|
+
peerPanel.overlay.handleInput("q");
|
|
571
|
+
await Promise.all([panel.opened, peerPanel.opened]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("manual agents command warns once in RPC mode and stays quiet without UI", async (t) => {
|
|
575
|
+
const local = liveInstance(t, liveProfile("live-non-tui"), "non-tui");
|
|
576
|
+
Object.assign(local.ctx, { mode: "rpc" });
|
|
577
|
+
const notify = t.mock.method(local.ctx.ui, "notify");
|
|
578
|
+
await local.commands.get("gentle:agents")!.handler("", local.ctx);
|
|
579
|
+
assert.equal(notify.mock.callCount(), 1);
|
|
580
|
+
assert.equal(notify.mock.calls[0].arguments[1], "warning");
|
|
581
|
+
assert.deepEqual(local.overlays, []);
|
|
582
|
+
Object.assign(local.ctx, { hasUI: false });
|
|
583
|
+
await local.commands.get("gentle:agents")!.handler("", local.ctx);
|
|
584
|
+
assert.equal(notify.mock.callCount(), 1, "headless invocation adds no notification");
|
|
585
|
+
assert.deepEqual(local.overlays, []);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
for (const scenario of ["recover", "shutdown", "replacement"] as const) {
|
|
589
|
+
test(`live presence after owned-target publication failure: ${scenario}`, async (t) => {
|
|
590
|
+
const profile = liveProfile(`live-io-${scenario}`);
|
|
591
|
+
const local = liveInstance(t, profile, "io-original");
|
|
592
|
+
await local.fire("session_start", local.ctx);
|
|
593
|
+
const original = listPresence(profile).entries[0]!;
|
|
594
|
+
const peer = scenario === "recover" ? liveInstance(t, profile, "io-original") : undefined;
|
|
595
|
+
if (peer) await peer.fire("session_start", peer.ctx);
|
|
596
|
+
const panel = peer ? await liveOverlay(local) : undefined;
|
|
597
|
+
if (panel) {
|
|
598
|
+
panel.overlay.handleInput("a");
|
|
599
|
+
await eventually(() => /io-original/.test(panel.frame()), "same-session peer is visible before publication failure");
|
|
600
|
+
}
|
|
601
|
+
const target = join(profile, "gentle-agents", "presence", `${original.sessionHash}.${original.incarnation}.activity.json`);
|
|
602
|
+
const fs = createRequire(import.meta.url)("node:fs") as typeof import("node:fs");
|
|
603
|
+
const rename = fs.renameSync;
|
|
604
|
+
let failures = 0;
|
|
605
|
+
const fault = t.mock.method(fs, "renameSync", (from, to) => {
|
|
606
|
+
if (to === target) {
|
|
607
|
+
failures++;
|
|
608
|
+
throw Object.assign(new Error("fixture publication failure"), { code: "EIO" });
|
|
609
|
+
}
|
|
610
|
+
return rename(from, to);
|
|
611
|
+
});
|
|
612
|
+
syncBuiltinESMExports();
|
|
613
|
+
try {
|
|
614
|
+
await local.tools.get("subagent_run")!.execute("io", { agent: "local", task: "Recover activity", mode: "background" }, undefined, undefined, local.ctx);
|
|
615
|
+
await eventually(() => failures > 0 && !listPresence(profile).entries.some((header) => header.incarnation === original.incarnation), "guarded flush failure must dispose the owned publication");
|
|
616
|
+
} finally {
|
|
617
|
+
fault.mock.restore();
|
|
618
|
+
syncBuiltinESMExports();
|
|
619
|
+
}
|
|
620
|
+
if (scenario === "shutdown") await local.fire("session_shutdown", local.ctx);
|
|
621
|
+
if (scenario === "replacement") {
|
|
622
|
+
const next = fakeContext().ctx;
|
|
623
|
+
next.sessionManager.getSessionId = () => "io-replacement";
|
|
624
|
+
await local.fire("session_start", next);
|
|
625
|
+
}
|
|
626
|
+
const replacement = listPresence(profile).entries[0];
|
|
627
|
+
local.children[0].emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "activity after IO recovery" } });
|
|
628
|
+
await tick();
|
|
629
|
+
if (scenario === "recover") {
|
|
630
|
+
await eventually(() => listPresence(profile).entries.some((header) => readActivity(profile, header).activity?.tasks.some((row) => row.thread.items.some((item) => item.text === "activity after IO recovery"))), "ordinary task delta must recreate presence with current activity");
|
|
631
|
+
const recovered = listPresence(profile).entries;
|
|
632
|
+
assert.equal(recovered.length, 2, "recovery preserves the distinct same-session peer");
|
|
633
|
+
assert.ok(recovered.every((header) => header.sessionHash === original.sessionHash));
|
|
634
|
+
assert.ok(recovered.every((header) => header.incarnation !== original.incarnation));
|
|
635
|
+
const scans = t.mock.method(PresenceCursor.prototype, "next");
|
|
636
|
+
await peer!.tools.get("subagent_run")!.execute("peer", { agent: "peer", task: "Peer after recovery", mode: "background" }, undefined, undefined, peer!.ctx);
|
|
637
|
+
// The second scan starts only after the first post-recovery traversal is displayed.
|
|
638
|
+
await eventually(() => scans.mock.callCount() >= 2 && /Subagent peer/.test(panel!.frame()), "same-session peer remains visible after recovery without reopening");
|
|
639
|
+
assert.equal((panel!.frame().match(/Subagent local/g) ?? []).length, 1, "recovered local activity appears once, never as a read-only peer duplicate");
|
|
640
|
+
assert.equal((panel!.frame().match(/Subagent peer/g) ?? []).length, 1, "the actual same-session peer remains independently visible");
|
|
641
|
+
} else if (scenario === "shutdown") {
|
|
642
|
+
assert.deepEqual(listPresence(profile).entries, [], "shutdown and late child events never recreate presence");
|
|
643
|
+
} else {
|
|
644
|
+
const headers = listPresence(profile).entries;
|
|
645
|
+
assert.equal(headers.length, 1, "late old-session activity cannot resurrect its publisher");
|
|
646
|
+
assert.notEqual(headers[0].sessionHash, original.sessionHash);
|
|
647
|
+
assert.equal(headers[0].incarnation, replacement!.incarnation);
|
|
648
|
+
assert.deepEqual(readActivity(profile, headers[0]).activity?.tasks, [], "replacement must not publish old-session tasks");
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
test("live-only instances sharing a session ID remain distinct and withdraw only their own incarnation", async (t) => {
|
|
654
|
+
const profile = liveProfile("live-incarnation-profile");
|
|
655
|
+
const local = liveInstance(t, profile, "same-live");
|
|
656
|
+
const peer = liveInstance(t, profile, "same-live");
|
|
657
|
+
peer.ctx.sessionManager.getCwd = () => join(realpathSync(root), "peer-cwd");
|
|
658
|
+
for (const [instance, agent] of [[local, "local"], [peer, "peer"]] as const) {
|
|
659
|
+
await instance.fire("session_start", instance.ctx, { reason: "startup" });
|
|
660
|
+
await instance.tools.get("subagent_run")!.execute(agent, { agent, task: agent, mode: "background" }, undefined, undefined, instance.ctx);
|
|
661
|
+
}
|
|
662
|
+
const headers = listPresence(profile).entries;
|
|
663
|
+
assert.equal(headers.length, 2);
|
|
664
|
+
assert.equal(headers[0].sessionHash, headers[1].sessionHash);
|
|
665
|
+
assert.notEqual(headers[0].incarnation, headers[1].incarnation);
|
|
666
|
+
const panel = await liveOverlay(local);
|
|
667
|
+
panel.overlay.handleInput("a");
|
|
668
|
+
await eventually(() => /Subagent peer/.test(panel.frame()), "same-session peers remain independently visible");
|
|
669
|
+
assert.equal((panel.frame().match(/Subagent local/g) ?? []).length, 1, "own publication is not duplicated as a peer");
|
|
670
|
+
await local.fire("session_shutdown", local.ctx, { reason: "quit" });
|
|
671
|
+
await panel.opened;
|
|
672
|
+
assert.equal(listPresence(profile).entries.length, 1, "shutdown removes only this activation");
|
|
673
|
+
assert.deepEqual(peer.children[0].killed, []);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("live-only directory traverses presence overflow, excludes expired and other-profile peers, and replaces sessions", async (t) => {
|
|
677
|
+
const profile = liveProfile("live-paged-profile");
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
const oldClock = mock.method(Date, "now", () => now - 16_000);
|
|
680
|
+
let expired: PresencePublisher;
|
|
681
|
+
try {
|
|
682
|
+
expired = PresencePublisher.start({ profile, sessionId: "expired", label: "Expired peer", activity: [] });
|
|
683
|
+
} finally {
|
|
684
|
+
oldClock.mock.restore();
|
|
685
|
+
}
|
|
686
|
+
// Simulate an abruptly closed publisher: retained files, but no renewing heartbeat.
|
|
687
|
+
const stem = join(profile, "gentle-agents", "presence", `${expired.target.sessionHash}.${expired.target.incarnation}`);
|
|
688
|
+
const staleFiles = ["header", "activity"].map((kind) => ({ path: `${stem}.${kind}.json`, bytes: readFileSync(`${stem}.${kind}.json`) }));
|
|
689
|
+
expired.dispose();
|
|
690
|
+
for (const { path, bytes } of staleFiles) writeFileSync(path, bytes, { mode: 0o600 });
|
|
691
|
+
const isolated = PresencePublisher.start({ profile: liveProfile("live-isolated-profile"), sessionId: "isolated", label: "Isolated peer", activity: [] });
|
|
692
|
+
t.after(() => isolated.dispose());
|
|
693
|
+
for (let index = 0; index < 130; index++) {
|
|
694
|
+
const publisher = PresencePublisher.start({ profile, sessionId: `idle-${index}`, label: `Idle peer ${index}`, activity: [] });
|
|
695
|
+
t.after(() => publisher.dispose());
|
|
696
|
+
}
|
|
697
|
+
assert.equal(listPresence(profile).overflow, true, "fixture exceeds the foundation's first-page budget");
|
|
698
|
+
let pageReadThisTurn = false;
|
|
699
|
+
const next = PresenceCursor.prototype.next;
|
|
700
|
+
const pages = t.mock.method(PresenceCursor.prototype, "next", function (this: PresenceCursor, ...args: Parameters<PresenceCursor["next"]>) {
|
|
701
|
+
assert.equal(pageReadThisTurn, false, "directory pages must yield instead of blocking the UI with an unbounded loop");
|
|
702
|
+
pageReadThisTurn = true;
|
|
703
|
+
setImmediate(() => { pageReadThisTurn = false; });
|
|
704
|
+
return next.apply(this, args);
|
|
705
|
+
});
|
|
706
|
+
const local = liveInstance(t, profile, "directory-local");
|
|
707
|
+
await local.fire("session_start", local.ctx, { reason: "startup" });
|
|
708
|
+
const panel = await liveOverlay(local);
|
|
709
|
+
panel.overlay.handleInput("a");
|
|
710
|
+
const seen = new Set<string>();
|
|
711
|
+
await eventually(() => {
|
|
712
|
+
for (let step = 0; step < 140; step++) {
|
|
713
|
+
const frame = panel.frame();
|
|
714
|
+
assert.doesNotMatch(frame, /Expired peer|Isolated peer/);
|
|
715
|
+
for (const match of frame.matchAll(/Idle peer (\d+)/g)) seen.add(match[1]);
|
|
716
|
+
panel.overlay.handleInput("j");
|
|
717
|
+
}
|
|
718
|
+
for (let step = 0; step < 140; step++) panel.overlay.handleInput("k");
|
|
719
|
+
return seen.size === 130;
|
|
720
|
+
}, "every idle orchestrator beyond the 128-entry page must eventually be reachable");
|
|
721
|
+
assert.ok(pages.mock.callCount() >= 3, "the directory traversed all three fixture pages");
|
|
722
|
+
panel.overlay.handleInput("q");
|
|
723
|
+
await panel.opened;
|
|
724
|
+
const readsAtClose = pages.mock.callCount();
|
|
725
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
726
|
+
assert.equal(pages.mock.callCount(), readsAtClose, "closing the overlay cancels future directory scans");
|
|
727
|
+
pages.mock.restore();
|
|
728
|
+
await local.fire("session_shutdown", local.ctx, { reason: "new" });
|
|
729
|
+
const replacement = liveInstance(t, profile, "replacement-live");
|
|
730
|
+
await replacement.fire("session_start", replacement.ctx, { reason: "new" });
|
|
731
|
+
const cursor = new PresenceCursor(profile);
|
|
732
|
+
const labels: string[] = [];
|
|
733
|
+
try {
|
|
734
|
+
let page;
|
|
735
|
+
do {
|
|
736
|
+
page = cursor.next();
|
|
737
|
+
labels.push(...page.entries.map((entry) => entry.label));
|
|
738
|
+
} while (page.overflow);
|
|
739
|
+
} finally {
|
|
740
|
+
cursor.close();
|
|
741
|
+
}
|
|
742
|
+
assert.ok(labels.some((label) => label.includes("replacement-live")));
|
|
743
|
+
assert.ok(labels.every((label) => !label.includes("directory-local")), "session replacement withdraws the old activation");
|
|
744
|
+
panel.overlay.handleInput("q");
|
|
745
|
+
await panel.opened;
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("research launch transports selected grants and only matching existing extensions", async () => {
|
|
749
|
+
const fixtureHome = join(root, "research-home");
|
|
750
|
+
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
751
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-research.md"), "---\nname: sdd-research\ntools: [read, write, fetch_content, web_search, source_check, mcp, bash]\n---\nCollect research.");
|
|
752
|
+
const fake = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
753
|
+
fake.pi.getActiveTools = () => ["fetch_content", "web_search", "mcp", "bash"];
|
|
754
|
+
fake.pi.getAllTools = () => fake.pi.getActiveTools().map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) as never;
|
|
755
|
+
const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
|
|
756
|
+
const artifact = { store: "openspec", worktree: cwd, changeName: "demo", retainedIntent: "preserve denied documentation questions", locators: [{ artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: "a".repeat(64) }] };
|
|
757
|
+
let childEnv: NodeJS.ProcessEnv = {};
|
|
758
|
+
gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
|
|
759
|
+
childEnv = options.env!;
|
|
760
|
+
return runtime.deps.spawn!(command, args, options);
|
|
761
|
+
} });
|
|
762
|
+
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, research_artifact: artifact }, undefined, undefined, ctx);
|
|
763
|
+
await tick();
|
|
764
|
+
const argv = runtime.spawned[0];
|
|
765
|
+
assert.equal(argv[argv.indexOf("--tools") + 1], "read,write,fetch_content,subagent_parent_message");
|
|
766
|
+
assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
|
|
767
|
+
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
|
|
768
|
+
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
|
|
769
|
+
assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact);
|
|
770
|
+
assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
|
|
771
|
+
assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
|
|
772
|
+
assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
|
|
773
|
+
assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /open-web: blocked/, "two reachable tools cannot admit open-web");
|
|
774
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
775
|
+
await tick();
|
|
776
|
+
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
777
|
+
const rejected = await fake.tools.get("subagent_continue")!.execute("broaden", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: { ...artifact, store: "none", locators: [] } }, undefined, undefined, ctx);
|
|
778
|
+
assert.match(rejected.content[0].text, /scope/);
|
|
779
|
+
assert.equal(runtime.spawned.length, 1);
|
|
780
|
+
await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: artifact }, undefined, undefined, ctx);
|
|
781
|
+
await tick();
|
|
782
|
+
assert.equal(runtime.spawned.length, 2);
|
|
783
|
+
assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
|
|
784
|
+
assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "read,write,subagent_parent_message");
|
|
785
|
+
assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
|
|
786
|
+
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact, "denial intent and exact store/path survive re-entry");
|
|
787
|
+
fake.pi.getActiveTools = () => ["web_search"];
|
|
788
|
+
runtime.children[1].emit({ type: "agent_settled" });
|
|
789
|
+
await tick();
|
|
790
|
+
const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection, research_artifact: artifact }, undefined, undefined, ctx);
|
|
791
|
+
await tick();
|
|
792
|
+
assert.ok(!runtime.spawned[2].includes("--extension"));
|
|
793
|
+
runtime.children[2].emit({ type: "agent_settled" });
|
|
794
|
+
await tick();
|
|
795
|
+
fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
|
|
796
|
+
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, research_artifact: artifact }, undefined, undefined, ctx);
|
|
797
|
+
await tick();
|
|
798
|
+
assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
|
|
799
|
+
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
|
|
800
|
+
await fake.fire("session_shutdown", ctx);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("research child inventory requires every canonical open-web tool", () => {
|
|
804
|
+
const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
|
|
805
|
+
for (const missing of [undefined, ...required]) {
|
|
806
|
+
const hooks = new Map<string, (event: any) => any>();
|
|
807
|
+
const active = required.filter(name => name !== missing);
|
|
808
|
+
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;
|
|
809
|
+
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"])) } }) });
|
|
810
|
+
const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
|
|
811
|
+
assert.match(prompt, new RegExp(`open-web: ${missing === undefined ? "available" : "blocked"}`));
|
|
812
|
+
assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
|
|
813
|
+
assert.match(prompt, /Availability is not evidence/);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("research child rechecks local inventory and blocks gateway calls", async () => {
|
|
818
|
+
const hooks = new Map<string, (event: any) => any>();
|
|
819
|
+
const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => ["read", "mcp"], getAllTools: () => [{ name: "read" }, { name: "mcp" }] } as never;
|
|
820
|
+
gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: '["read","fetch_content"]' });
|
|
821
|
+
assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
|
|
822
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
|
|
823
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
|
|
824
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing artifact scope cannot authorize a read");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
async function shutdownAndRestoreNativeSpawn(
|
|
828
|
+
childProcess: typeof import("node:child_process"),
|
|
829
|
+
originalSpawn: typeof import("node:child_process").spawn,
|
|
830
|
+
shutdown: () => Promise<unknown>,
|
|
831
|
+
): Promise<void> {
|
|
832
|
+
try {
|
|
833
|
+
await shutdown();
|
|
834
|
+
} finally {
|
|
835
|
+
childProcess.spawn = originalSpawn;
|
|
836
|
+
syncBuiltinESMExports();
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
for (const scenario of ["own", "other-root", "escaped", "sibling", "session-switch", "shutdown", "unregistered"] as const) {
|
|
841
|
+
test(`child mutation attribution through registered subagent_run: ${scenario}`, async () => {
|
|
842
|
+
const h = fakePi();
|
|
843
|
+
const d = deps();
|
|
844
|
+
const { ctx } = fakeContext();
|
|
845
|
+
let sessionId = "s1";
|
|
846
|
+
const sibling = join(root, "sibling");
|
|
847
|
+
const childRoot = scenario === "other-root" ? sibling : cwd;
|
|
848
|
+
ctx.sessionManager.getSessionId = () => sessionId;
|
|
849
|
+
ctx.sessionManager.getEntries = (() => h.entries) as typeof ctx.sessionManager.getEntries;
|
|
850
|
+
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
851
|
+
d.deps.resolveWorktree = (path, base) => {
|
|
852
|
+
const absolute = resolve(base, path);
|
|
853
|
+
const worktree = [cwd, sibling].find((candidate) => absolute === candidate || absolute.startsWith(`${candidate}/`));
|
|
854
|
+
return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
|
|
855
|
+
};
|
|
856
|
+
const spawn = d.deps.spawn!;
|
|
857
|
+
d.deps.spawn = (...args) => {
|
|
858
|
+
const child = spawn(...args);
|
|
859
|
+
const on = child.on.bind(child);
|
|
860
|
+
child.on = ((event: string, listener: () => void) => {
|
|
861
|
+
if (event === "spawn" && scenario !== "unregistered") queueMicrotask(listener);
|
|
862
|
+
return on(event as "spawn", listener);
|
|
863
|
+
}) as typeof child.on;
|
|
864
|
+
return child;
|
|
865
|
+
};
|
|
866
|
+
gentleAgents(h.pi, {}, d.deps);
|
|
867
|
+
await h.fire("session_start", ctx);
|
|
868
|
+
await h.tools.get("subagent_run")!.execute("mutation", { agent: "explore", task: "Write", mode: "background", workspace_root: childRoot }, undefined, undefined, ctx);
|
|
869
|
+
await tick();
|
|
870
|
+
assert.equal(h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT).length, 0, "spawn alone is not ownership");
|
|
871
|
+
if (scenario === "session-switch") sessionId = "s2";
|
|
872
|
+
if (scenario === "shutdown") await h.fire("session_shutdown", ctx);
|
|
873
|
+
const path = scenario === "escaped" ? "../../outside.ts" : scenario === "sibling" ? join(sibling, "file.ts") : "file.ts";
|
|
874
|
+
d.children[0].emit({ type: "tool_execution_start", toolCallId: "write", toolName: "write", args: { path } });
|
|
875
|
+
d.children[0].emit({ type: "tool_execution_end", toolCallId: "write", isError: false, result: { content: [] } });
|
|
876
|
+
const accepted = scenario === "own" || scenario === "other-root";
|
|
877
|
+
assert.equal(h.entries.filter((entry) => entry.customType === REVIEW_REMINDER_RECEIPT).length, accepted ? 1 : 0);
|
|
878
|
+
assert.equal(Boolean(pendingReviewMutation(ctx.sessionManager, cwd)), scenario === "own", "another registered root never authorizes current-root STATUS");
|
|
879
|
+
if (scenario === "other-root") assert.ok(pendingReviewMutation(ctx.sessionManager, sibling));
|
|
880
|
+
await h.fire("session_shutdown", ctx);
|
|
881
|
+
await tick();
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
test("default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children", async () => {
|
|
886
|
+
const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
|
|
887
|
+
const originalSpawn = childProcess.spawn;
|
|
888
|
+
type CapturedSpawnOptions = { cwd: string; env: NodeJS.ProcessEnv; shell?: boolean; windowsHide?: boolean; detached?: boolean; stdio?: string[] };
|
|
889
|
+
const captured: Array<{ command: string; args: readonly string[]; options: CapturedSpawnOptions }> = [];
|
|
890
|
+
const children: FakeChild[] = [];
|
|
891
|
+
const shutdown: Array<() => Promise<void>> = [];
|
|
892
|
+
const canonicalGitCwd = join(root, "canonical-git-project");
|
|
893
|
+
const gitBin = join(root, "canonical-git-bin");
|
|
894
|
+
mkdirSync(join(canonicalGitCwd, ".git"), { recursive: true });
|
|
895
|
+
mkdirSync(gitBin, { recursive: true });
|
|
896
|
+
const gitFixture = join(gitBin, "git");
|
|
897
|
+
writeFileSync(gitFixture, `#!/bin/sh\nif [ "$1" = "-C" ] && [ "$2" = "${canonicalGitCwd}" ] && [ "$3" = "rev-parse" ] && [ "$4" = "--git-common-dir" ]; then\n printf '.git\\n'\n exit 0\nfi\nexit 1\n`);
|
|
898
|
+
chmodSync(gitFixture, 0o755);
|
|
899
|
+
const withCanonicalGitFixture = <T>(action: () => T): T => {
|
|
900
|
+
const previousPath = process.env.PATH;
|
|
901
|
+
process.env.PATH = gitBin;
|
|
902
|
+
try {
|
|
903
|
+
return action();
|
|
904
|
+
} finally {
|
|
905
|
+
if (previousPath === undefined) delete process.env.PATH;
|
|
906
|
+
else process.env.PATH = previousPath;
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
|
|
910
|
+
captured.push({ command, args, options: options as unknown as CapturedSpawnOptions });
|
|
911
|
+
const child = fakeChild();
|
|
912
|
+
children.push(child);
|
|
913
|
+
return child.child;
|
|
914
|
+
}) as unknown as typeof childProcess.spawn;
|
|
915
|
+
syncBuiltinESMExports();
|
|
916
|
+
try {
|
|
917
|
+
const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
|
|
918
|
+
const h = fakePi();
|
|
919
|
+
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined });
|
|
920
|
+
const { ctx } = fakeContext();
|
|
921
|
+
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
922
|
+
await h.fire("session_start", ctx);
|
|
923
|
+
shutdown.push(() => h.fire("session_shutdown", ctx));
|
|
924
|
+
return { h, ctx, result: withCanonicalGitFixture(() => h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx)) };
|
|
925
|
+
};
|
|
926
|
+
const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
|
|
927
|
+
await tick();
|
|
928
|
+
children[0]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "task complete" }] }] });
|
|
929
|
+
children[0]!.emit({ type: "agent_settled" });
|
|
930
|
+
await task.result;
|
|
931
|
+
const background = await launch("background", { PATH: "/bin", FIXTURE: "background" });
|
|
932
|
+
await background.result;
|
|
933
|
+
await tick();
|
|
934
|
+
const permission = await launch("task", { PATH: "/bin", FIXTURE: "permission" }, canonicalGitCwd);
|
|
935
|
+
await tick();
|
|
936
|
+
children[2]!.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "permission complete" }] }] });
|
|
937
|
+
children[2]!.emit({ type: "agent_settled" });
|
|
938
|
+
await permission.result;
|
|
939
|
+
|
|
940
|
+
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."];
|
|
941
|
+
assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
|
|
942
|
+
for (const [index, fixture] of ["task", "background", "permission"].entries()) {
|
|
943
|
+
const permissionChannel = index === 2;
|
|
944
|
+
const ownedIpc = captured[index]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
|
|
945
|
+
assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the child receives an opaque owned-IPC marker");
|
|
946
|
+
assert.equal(captured[index]?.command, "/fixture/pi");
|
|
947
|
+
assert.deepEqual(captured[index]?.args, args);
|
|
948
|
+
assert.equal(captured[index]?.options.cwd, permissionChannel ? canonicalGitCwd : nonGitCwd);
|
|
949
|
+
assert.deepEqual(captured[index]?.options.env, { PATH: "/bin", FIXTURE: fixture, GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, ...(permissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}) });
|
|
950
|
+
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
951
|
+
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
952
|
+
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
953
|
+
assert.deepEqual(captured[index]?.options.stdio, permissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"], permissionChannel ? "canonical repository children retain an fd3 permission channel and receive messaging IPC at fd4" : "IPC-only children have no inherited permission fd");
|
|
954
|
+
}
|
|
955
|
+
await Promise.all(shutdown.map((close) => close()));
|
|
956
|
+
assert.deepEqual(children[1]?.killed, ["SIGTERM"], "session shutdown cleans up an active background child");
|
|
957
|
+
shutdown.length = 0;
|
|
958
|
+
} finally {
|
|
959
|
+
await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("native spawn interception restores CommonJS and ESM exports after rejected shutdown and assertion failure", async () => {
|
|
964
|
+
const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
|
|
965
|
+
const originalSpawn = childProcess.spawn;
|
|
966
|
+
const assertRestored = async () => {
|
|
967
|
+
assert.equal(childProcess.spawn, originalSpawn, "CommonJS spawn is restored");
|
|
968
|
+
assert.equal((await import("node:child_process")).spawn, originalSpawn, "ESM spawn is restored");
|
|
969
|
+
};
|
|
970
|
+
const installMock = () => {
|
|
971
|
+
childProcess.spawn = (() => fakeChild().child) as unknown as typeof childProcess.spawn;
|
|
972
|
+
syncBuiltinESMExports();
|
|
973
|
+
};
|
|
974
|
+
const start = async (rejectShutdown: boolean) => {
|
|
975
|
+
const h = fakePi();
|
|
976
|
+
const fire = h.fire;
|
|
977
|
+
if (rejectShutdown) {
|
|
978
|
+
h.fire = async (event, ctx, payload) => {
|
|
979
|
+
await fire(event, ctx, payload);
|
|
980
|
+
if (event === "session_shutdown") throw new Error("forced shutdown rejection");
|
|
981
|
+
return undefined;
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined });
|
|
985
|
+
const { ctx } = fakeContext();
|
|
986
|
+
await h.fire("session_start", ctx);
|
|
987
|
+
await h.tools.get("subagent_run")!.execute("cleanup", { agent: "explore", task: "Keep cleanup live", mode: "background" }, undefined, undefined, ctx);
|
|
988
|
+
await tick();
|
|
989
|
+
return { h, ctx };
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
let mocked = false;
|
|
993
|
+
installMock();
|
|
994
|
+
mocked = true;
|
|
995
|
+
try {
|
|
996
|
+
const rejected = await start(true);
|
|
997
|
+
await assert.rejects(shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => rejected.h.fire("session_shutdown", rejected.ctx)), /forced shutdown rejection/);
|
|
998
|
+
mocked = false;
|
|
999
|
+
await assertRestored();
|
|
1000
|
+
} finally {
|
|
1001
|
+
if (mocked) {
|
|
1002
|
+
childProcess.spawn = originalSpawn;
|
|
1003
|
+
syncBuiltinESMExports();
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
installMock();
|
|
1008
|
+
mocked = true;
|
|
1009
|
+
try {
|
|
1010
|
+
const asserted = await start(false);
|
|
1011
|
+
await assert.rejects(async () => {
|
|
1012
|
+
try {
|
|
1013
|
+
assert.fail("forced assertion failure");
|
|
1014
|
+
} finally {
|
|
1015
|
+
await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => asserted.h.fire("session_shutdown", asserted.ctx));
|
|
1016
|
+
}
|
|
1017
|
+
}, /forced assertion failure/);
|
|
1018
|
+
mocked = false;
|
|
1019
|
+
await assertRestored();
|
|
1020
|
+
} finally {
|
|
1021
|
+
if (mocked) {
|
|
1022
|
+
childProcess.spawn = originalSpawn;
|
|
1023
|
+
syncBuiltinESMExports();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test("explicit child roots launch and continue in the actual cwd, persist without shell, and reject other clones", async () => {
|
|
1029
|
+
const h = fakePi();
|
|
1030
|
+
const runtime = deps();
|
|
1031
|
+
const childRoot = join(root, "child-worktree");
|
|
1032
|
+
const launched: string[] = [];
|
|
1033
|
+
const spawnEvents: Array<() => void> = [];
|
|
1034
|
+
const baseSpawn = runtime.deps.spawn!;
|
|
1035
|
+
runtime.deps.spawn = (command, args, options) => {
|
|
1036
|
+
launched.push(options.cwd);
|
|
1037
|
+
const child = baseSpawn(command, args, options);
|
|
1038
|
+
const on = child.on.bind(child);
|
|
1039
|
+
child.on = ((event: string, listener: () => void) => {
|
|
1040
|
+
if (event === "spawn") spawnEvents.push(listener);
|
|
1041
|
+
else on(event as "exit", listener);
|
|
1042
|
+
return child;
|
|
1043
|
+
}) as typeof child.on;
|
|
1044
|
+
return child;
|
|
1045
|
+
};
|
|
1046
|
+
runtime.deps.resolveWorktree = (path, base) => ({ root: resolve(base, path), commonDir: path === "/other-clone" ? "/other/git" : "/fixture/common" });
|
|
1047
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
1048
|
+
const { ctx } = fakeContext();
|
|
1049
|
+
(ctx.sessionManager as unknown as { getEntries(): unknown[] }).getEntries = () => h.entries;
|
|
1050
|
+
await h.fire("session_start", ctx);
|
|
1051
|
+
const run = h.tools.get("subagent_run")!;
|
|
1052
|
+
await assert.rejects(run.execute("bad", { agent: "explore", task: "Map", workspace_root: "/other-clone", mode: "background" }, undefined, undefined, ctx), /same Git clone/);
|
|
1053
|
+
assert.deepEqual(launched, []);
|
|
1054
|
+
const result = await run.execute("one", { agent: "explore", task: "Map /other-clone mentioned in prose", workspace_root: childRoot, mode: "background" }, undefined, undefined, ctx);
|
|
1055
|
+
await tick();
|
|
1056
|
+
assert.deepEqual(launched, [childRoot]);
|
|
1057
|
+
assert.deepEqual(h.entries, [], "queueing and returning a child handle do not register roots");
|
|
1058
|
+
spawnEvents[0]();
|
|
1059
|
+
assert.deepEqual(h.entries, [{ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId: "s1", root: childRoot, evidence: "subagent:spawn" } }]);
|
|
1060
|
+
assert.deepEqual(h.events.filter(event => event.name === SESSION_WORKTREE_CHANGED), [{ name: SESSION_WORKTREE_CHANGED, data: { sessionId: "s1" } }]);
|
|
1061
|
+
const details = result.details.gentleAgents as { taskId: string; cwd: string };
|
|
1062
|
+
assert.equal(details.cwd, childRoot);
|
|
1063
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
1064
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
1065
|
+
await tick();
|
|
1066
|
+
await h.tools.get("subagent_continue")!.execute("continue", { task_id: details.taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
|
|
1067
|
+
await tick();
|
|
1068
|
+
assert.deepEqual(launched, [childRoot, childRoot]);
|
|
1069
|
+
spawnEvents[1]();
|
|
1070
|
+
assert.equal(h.entries.length, 1, "continuation dedupes the original root");
|
|
1071
|
+
const status = await h.tools.get("subagent_status")!.execute("status", { task_id: details.taskId }, undefined, undefined, ctx);
|
|
1072
|
+
assert.match(status.content[0].text, /cwd:/);
|
|
1073
|
+
await h.fire("session_shutdown", ctx);
|
|
1074
|
+
spawnEvents[1]();
|
|
1075
|
+
assert.equal(h.entries.length, 1, "late process events after shutdown cannot write session state");
|
|
1076
|
+
await tick();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test("SDD phase continuation requires a fresh selection and launches only that selection", async () => {
|
|
1080
|
+
const h = fakePi();
|
|
1081
|
+
const runtime = deps();
|
|
1082
|
+
const fixtureHome = join(root, "sdd-selection-home");
|
|
1083
|
+
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
1084
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-apply.md"), "---\ndescription: apply\ntools: [read]\n---\nSDD apply executor");
|
|
1085
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome });
|
|
1086
|
+
const { ctx } = fakeContext();
|
|
1087
|
+
await h.fire("session_start", ctx);
|
|
1088
|
+
const run = await h.tools.get("subagent_run")!.execute("run", {
|
|
1089
|
+
agent: "sdd-apply", task: "Apply alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
|
|
1090
|
+
sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "apply" },
|
|
1091
|
+
}, undefined, undefined, ctx);
|
|
1092
|
+
await tick();
|
|
1093
|
+
const taskId = (run.details.gentleAgents as { taskId: string }).taskId;
|
|
1094
|
+
const first = runtime.spawned[0]!;
|
|
1095
|
+
assert.deepEqual(JSON.parse(first[first.indexOf("--gentle-sdd-change") + 1]!), { changeName: "alpha", workspaceRoot: cwd, phase: "apply" });
|
|
1096
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }] });
|
|
1097
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
1098
|
+
await tick();
|
|
1099
|
+
const missing = await h.tools.get("subagent_continue")!.execute("missing", { task_id: taskId, prompt: "Continue", mode: "background" }, undefined, undefined, ctx);
|
|
1100
|
+
assert.match(missing.content[0].text, /requires a fresh sdd_change/i);
|
|
1101
|
+
assert.equal(runtime.spawned.length, 1);
|
|
1102
|
+
await h.tools.get("subagent_continue")!.execute("continue", {
|
|
1103
|
+
task_id: taskId, prompt: "Apply beta", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background",
|
|
1104
|
+
sdd_change: { changeName: "beta", workspaceRoot: cwd, phase: "apply" },
|
|
1105
|
+
}, undefined, undefined, ctx);
|
|
1106
|
+
await tick();
|
|
1107
|
+
const second = runtime.spawned[1]!;
|
|
1108
|
+
assert.deepEqual(JSON.parse(second[second.indexOf("--gentle-sdd-change") + 1]!), { changeName: "beta", workspaceRoot: cwd, phase: "apply" });
|
|
1109
|
+
await h.fire("session_shutdown", ctx);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("ordinary non-Git tasks still continue in their original cwd without registering a worktree", async () => {
|
|
1113
|
+
const h = fakePi();
|
|
1114
|
+
const runtime = deps();
|
|
1115
|
+
runtime.deps.resolveWorktree = () => undefined;
|
|
1116
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
1117
|
+
const { ctx } = fakeContext();
|
|
1118
|
+
await h.fire("session_start", ctx);
|
|
1119
|
+
const result = await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
1120
|
+
await tick();
|
|
1121
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
1122
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
1123
|
+
await tick();
|
|
1124
|
+
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
1125
|
+
await h.tools.get("subagent_continue")!.execute("continue", { task_id: taskId, prompt: "Follow up", mode: "background" }, undefined, undefined, ctx);
|
|
1126
|
+
await tick();
|
|
1127
|
+
assert.equal(runtime.children.length, 2);
|
|
1128
|
+
assert.deepEqual(h.entries, []);
|
|
1129
|
+
await h.fire("session_shutdown", ctx);
|
|
1130
|
+
await tick();
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
test("delayed child spawn retains the originating session and cannot append into its replacement", async () => {
|
|
1134
|
+
const h = fakePi();
|
|
1135
|
+
const runtime = deps();
|
|
1136
|
+
const spawnEvents: Array<() => void> = [];
|
|
1137
|
+
const baseSpawn = runtime.deps.spawn!;
|
|
1138
|
+
runtime.deps.spawn = (command, args, options) => {
|
|
1139
|
+
const child = baseSpawn(command, args, options);
|
|
1140
|
+
const on = child.on.bind(child);
|
|
1141
|
+
child.on = ((event: string, listener: () => void) => {
|
|
1142
|
+
if (event === "spawn") spawnEvents.push(listener);
|
|
1143
|
+
else on(event as "exit", listener);
|
|
1144
|
+
return child;
|
|
1145
|
+
}) as typeof child.on;
|
|
1146
|
+
return child;
|
|
1147
|
+
};
|
|
1148
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
1149
|
+
const { ctx } = fakeContext();
|
|
1150
|
+
await h.fire("session_start", ctx);
|
|
1151
|
+
await h.tools.get("subagent_run")!.execute("run", { agent: "explore", task: "Map", workspace_root: join(root, "old-root"), mode: "background" }, undefined, undefined, ctx);
|
|
1152
|
+
await tick();
|
|
1153
|
+
const next = fakeContext();
|
|
1154
|
+
(next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
|
|
1155
|
+
await h.fire("session_start", next.ctx);
|
|
1156
|
+
spawnEvents[0]();
|
|
1157
|
+
await tick();
|
|
1158
|
+
assert.deepEqual(h.entries, [], "captured registry is closed instead of appending to the new bound API");
|
|
1159
|
+
await h.fire("session_shutdown", next.ctx);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
144
1162
|
test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
|
|
145
1163
|
assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
|
|
146
1164
|
sessions: "/profiles/pi-principal/agent/gentle-agents/sessions",
|
|
@@ -249,7 +1267,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
249
1267
|
gentleAgents(pi, {}, harness.deps);
|
|
250
1268
|
const { ctx, widget } = fakeContext();
|
|
251
1269
|
await fire("session_start", ctx);
|
|
252
|
-
assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
|
|
1270
|
+
assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
|
|
253
1271
|
const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
|
|
254
1272
|
assert.match(listed.content[0].text, /- explore \(global\): maps things/);
|
|
255
1273
|
|
|
@@ -257,15 +1275,15 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
257
1275
|
await tick();
|
|
258
1276
|
const [args] = harness.spawned;
|
|
259
1277
|
assert.equal(args[args.indexOf("--model") + 1], "openai-codex/gpt-5.6-terra:low", "the profile effort overrides the definition");
|
|
260
|
-
assert.equal(args[args.indexOf("--tools") + 1], "read,grep");
|
|
1278
|
+
assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
|
|
261
1279
|
await tick();
|
|
262
1280
|
assert.match(String(harness.children[0].written[1].message), /Map lib\/ and report every module\.\n\n## Context\nFocus on agents-\*\.ts/);
|
|
263
|
-
assert.match(widget()![0], /^╭─ ❀ Agents · 1 active
|
|
264
|
-
assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · \d+s │$/);
|
|
1281
|
+
assert.match(widget()![0], /^╭─ ❀ Agents · 1 active ─+╮$/);
|
|
1282
|
+
assert.match(widget()![1], /^│ ◐ explore map lib modules +gpt-5\.6-terra · low · \d+s │$/);
|
|
265
1283
|
harness.children[0].emit({ type: "tool_execution_start", toolCallId: "c", toolName: "grep", args: {} });
|
|
266
1284
|
harness.children[0].emit({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 12_000, cost: { total: 0.09 } } } });
|
|
267
1285
|
await tick();
|
|
268
|
-
assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · 12k · \$0\.09 · \d+s │$/);
|
|
1286
|
+
assert.match(widget()![1], /◐ explore map lib modules +gpt-5\.6-terra · low · 12k · \$0\.09 · \d+s │$/);
|
|
269
1287
|
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "lib has three agent files." }] }] });
|
|
270
1288
|
harness.children[0].emit({ type: "agent_settled" });
|
|
271
1289
|
const result = await running;
|
|
@@ -304,7 +1322,12 @@ test("background runs return at once; status, result, send_message, cancel, and
|
|
|
304
1322
|
assert.equal((await tools.get("subagent_result")!.execute("c7", { task_id: id }, undefined, undefined, ctx)).content[0].text, "All done.");
|
|
305
1323
|
assert.equal(sent.length, 1, "a background result is delivered to the model once");
|
|
306
1324
|
assert.equal(sent[0].message.customType, "gentle-agents.result");
|
|
307
|
-
assert.
|
|
1325
|
+
assert.equal(sent[0].message.display, true, "completion cards remain visible");
|
|
1326
|
+
// The completion path must never regress to "followUp": the host drains the
|
|
1327
|
+
// follow-up queue only when the parent run stops calling tools, which is the
|
|
1328
|
+
// hour-long #867 delay. "steer" keeps the idle wake-up (triggerTurn) while
|
|
1329
|
+
// bounding an active parent's wait to the current turn.
|
|
1330
|
+
assert.deepEqual(sent[0].options, { deliverAs: "steer", triggerTurn: true });
|
|
308
1331
|
assert.match(String(sent[0].message.content), new RegExp(`^Subagent explore \\(task ${id}, "Long job"\\) finished\\.\n\nAll done\\.$`));
|
|
309
1332
|
const card = renderers.get("gentle-agents.result")!(sent[0].message, { expanded: true }, plainTheme).render(70).map(stripAnsi);
|
|
310
1333
|
assert.match(card[0], /^╭─ ❀ Agent result · explore ─+ collapse ╮$/);
|
|
@@ -390,7 +1413,7 @@ test("AgentsView production composition observes each pointer event once and acc
|
|
|
390
1413
|
const { pi, tools, fire, commands } = fakePi();
|
|
391
1414
|
const harness = deps();
|
|
392
1415
|
gentleAgents(pi, {}, harness.deps);
|
|
393
|
-
const { ctx, overlays } = fakeContext();
|
|
1416
|
+
const { ctx, overlays, customCompletions } = fakeContext();
|
|
394
1417
|
await fire("session_start", ctx);
|
|
395
1418
|
|
|
396
1419
|
await tools.get("subagent_run")!.execute("b", { agent: "explore", task: "b", mode: "background" }, undefined, undefined, ctx);
|
|
@@ -422,6 +1445,7 @@ test("AgentsView production composition observes each pointer event once and acc
|
|
|
422
1445
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
423
1446
|
const overlay = overlays[0];
|
|
424
1447
|
assert.ok(overlay, "the production extension mounted its fullscreen interaction");
|
|
1448
|
+
overlay.handleInput("a"); // Directory headings remain non-actionable; Current has no wrapper.
|
|
425
1449
|
const lines = overlay.render(80);
|
|
426
1450
|
const dispatch = (event: TuiMouseEvent) => {
|
|
427
1451
|
const before = beforeCalls;
|
|
@@ -431,14 +1455,22 @@ test("AgentsView production composition observes each pointer event once and acc
|
|
|
431
1455
|
return result;
|
|
432
1456
|
};
|
|
433
1457
|
|
|
434
|
-
assert.match(stripAnsi(overlay.render(80)[1] ?? ""),
|
|
435
|
-
assert.
|
|
436
|
-
assert.match(stripAnsi(overlay.render(80)[
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
assert.
|
|
441
|
-
assert.
|
|
1458
|
+
assert.match(stripAnsi(overlay.render(80)[1] ?? ""), /Current orchestrator/, "the parent heading is the first visible row");
|
|
1459
|
+
assert.doesNotMatch(stripAnsi(overlay.render(80)[1] ?? ""), /▸/, "the heading is not a selected task");
|
|
1460
|
+
assert.match(stripAnsi(overlay.render(80)[2] ?? ""), /▸ └ .*Subagent/, "the first child starts selected beneath its heading");
|
|
1461
|
+
overlay.handleInput("k");
|
|
1462
|
+
overlay.handleInput("s");
|
|
1463
|
+
overlay.handleInput("o");
|
|
1464
|
+
assert.deepEqual(harness.children.map((child) => child.killed), [[], []], "heading actions never stop a child");
|
|
1465
|
+
assert.deepEqual(customCompletions, [], "heading actions never open a child session");
|
|
1466
|
+
overlay.handleInput("j");
|
|
1467
|
+
assert.equal(dispatch(mouse("click", "right", 4, 3, 80, lines.length)), undefined, "right click is inert");
|
|
1468
|
+
assert.match(stripAnsi(overlay.render(80)[2] ?? ""), /▸ └ .*Subagent/, "right click cannot select another child");
|
|
1469
|
+
assert.equal(dispatch(mouse("press", "left", 4, 3, 80, lines.length)), undefined, "press is inert");
|
|
1470
|
+
assert.equal(dispatch(mouse("click", "middle", 4, 3, 80, lines.length)), undefined, "middle click is inert");
|
|
1471
|
+
const leftClick = dispatch(mouse("click", "left", 4, 3, 80, lines.length));
|
|
1472
|
+
assert.equal((leftClick as { handled?: boolean } | undefined)?.handled, true, "left click selects a child task");
|
|
1473
|
+
assert.match(stripAnsi(overlay.render(80)[3] ?? ""), /▸ └ .*Subagent/, "left click selects the second child");
|
|
442
1474
|
overlay.handleInput("\x1b");
|
|
443
1475
|
await opened;
|
|
444
1476
|
} finally {
|
|
@@ -484,7 +1516,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
|
|
|
484
1516
|
let open = buttons(lines, "[ Open session ]");
|
|
485
1517
|
assert.match(lines[1] ?? "", /寿司/, "a unicode task remains inside the body, not the header or footer");
|
|
486
1518
|
assert.match(lines[follow.y] ?? "", /s Stop selected.*a all sessions/, "the existing stop and scope shortcuts remain beside the footer buttons");
|
|
487
|
-
assert.
|
|
1519
|
+
assert.match(overlay.render(59).map(stripAnsi).at(-2) ?? "", /\[Scope\]/, "the narrow list keeps mouse-accessible scope controls");
|
|
488
1520
|
lines = overlay.render(160).map(stripAnsi);
|
|
489
1521
|
follow = buttons(lines, "[ Follow ]");
|
|
490
1522
|
open = buttons(lines, "[ Open session ]");
|
|
@@ -522,7 +1554,7 @@ test("AgentsView production footer uses rendered bounds and invalidates them bef
|
|
|
522
1554
|
}
|
|
523
1555
|
});
|
|
524
1556
|
|
|
525
|
-
test("finished tasks
|
|
1557
|
+
test("finished tasks remain available through resolveTask but never reappear in the live-only overlay", async () => {
|
|
526
1558
|
const { pi, tools, fire, commands, shortcuts } = fakePi();
|
|
527
1559
|
const harness = deps();
|
|
528
1560
|
gentleAgents(pi, {}, harness.deps);
|
|
@@ -553,8 +1585,10 @@ test("finished tasks are written to history, come back through resolveTask, and
|
|
|
553
1585
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
554
1586
|
const overlay = overlays[0];
|
|
555
1587
|
assert.ok(overlay, "the overlay component was created");
|
|
556
|
-
assert.
|
|
557
|
-
|
|
1588
|
+
assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /finished|Subagent explore|Current orchestrator/);
|
|
1589
|
+
overlay.handleInput("a");
|
|
1590
|
+
overlay.handleInput("\x1b[C");
|
|
1591
|
+
assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /✓ Subagent explore/, "all sessions is not a historical-task browser");
|
|
558
1592
|
overlay.handleInput("\x1b");
|
|
559
1593
|
await opened;
|
|
560
1594
|
});
|
|
@@ -647,19 +1681,20 @@ test("the overlay explains that stopping a waiting subagent dismisses its questi
|
|
|
647
1681
|
await opened;
|
|
648
1682
|
});
|
|
649
1683
|
|
|
650
|
-
test("restored task history cannot execute stop", async () => {
|
|
651
|
-
const { pi, fire, commands } = fakePi();
|
|
1684
|
+
test("restored task history cannot enter the live panel or execute stop even with the current session ID", async () => {
|
|
1685
|
+
const { pi, fire, commands, tools } = fakePi();
|
|
652
1686
|
const harness = deps();
|
|
653
1687
|
const historyHome = join(root, "history-home");
|
|
654
|
-
const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "
|
|
1688
|
+
const historical: TaskRecord = { id: "history-running", agent: "explore", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "s1", status: TASK_STATUS.RUNNING, createdAt: 1, startedAt: 1, endedAt: null, model: "m", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "working", lastActivityAt: 1, turns: 0, toolCalls: 0, tokens: 0, cost: 0 };
|
|
655
1689
|
await saveTask(historyDir(historyHome), historical, emptyThread());
|
|
656
1690
|
harness.deps.home = historyHome;
|
|
657
1691
|
gentleAgents(pi, {}, harness.deps);
|
|
658
1692
|
const { ctx, dialogs, overlays } = fakeContext();
|
|
659
1693
|
await fire("session_start", ctx);
|
|
1694
|
+
await tools.get("subagent_status")!.execute("restore", { task_id: historical.id }, undefined, undefined, ctx);
|
|
660
1695
|
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
661
1696
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
662
|
-
assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).
|
|
1697
|
+
assert.doesNotMatch(stripAnsi(overlays[0]!.render(80).join("\n")), /Stop selected|Subagent explore/);
|
|
663
1698
|
overlays[0]!.handleInput("s");
|
|
664
1699
|
overlays[0]!.handleInput("c");
|
|
665
1700
|
await tick();
|
|
@@ -716,9 +1751,9 @@ test("the card follows the active session: after /new the earlier session's task
|
|
|
716
1751
|
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
717
1752
|
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
718
1753
|
const overlay = overlays[0]!;
|
|
719
|
-
assert.match(stripAnsi(overlay.render(80)[0]), /this session · 0 active
|
|
1754
|
+
assert.match(stripAnsi(overlay.render(80)[0]), /this session · 0 active/, "the overlay opens on the active session");
|
|
720
1755
|
overlay.handleInput("a");
|
|
721
|
-
assert.
|
|
1756
|
+
assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator");
|
|
722
1757
|
overlay.handleInput("\x1b");
|
|
723
1758
|
await opened;
|
|
724
1759
|
sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd };
|
|
@@ -739,3 +1774,395 @@ test("the card caps its rows to the terminal height and says how many tasks are
|
|
|
739
1774
|
assert.match(card[0], /2 active · 4 queued/);
|
|
740
1775
|
assert.match(card[5], /^│ … 2 more · alt\+a to view +│$/);
|
|
741
1776
|
});
|
|
1777
|
+
|
|
1778
|
+
test("the production overlay reads terminal rows at render time without a minimum-height override", async () => {
|
|
1779
|
+
const { pi, fire, commands } = fakePi();
|
|
1780
|
+
const harness = deps();
|
|
1781
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1782
|
+
let rows = 10;
|
|
1783
|
+
const overlayTui = { terminal: { get rows() { return rows; } }, requestRender() {} };
|
|
1784
|
+
const { ctx, overlays, customOptions } = fakeContext(fakeTui, async () => true, async () => undefined, overlayTui);
|
|
1785
|
+
await fire("session_start", ctx);
|
|
1786
|
+
const opened = commands.get("gentle:agents")!.handler("", ctx);
|
|
1787
|
+
for (let attempt = 0; attempt < 40 && overlays.length === 0; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1788
|
+
const overlay = overlays[0]!;
|
|
1789
|
+
assert.deepEqual(customOptions[0], { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } });
|
|
1790
|
+
assert.equal(overlay.render(80).length, 10, "the overlay uses the full terminal height");
|
|
1791
|
+
rows = 5;
|
|
1792
|
+
assert.equal(overlay.render(80).length, 5, "a live terminal resize changes the production frame budget");
|
|
1793
|
+
rows = 2;
|
|
1794
|
+
assert.equal(overlay.render(80).length, 1, "tiny terminals retain bounded controls rather than forced chrome");
|
|
1795
|
+
overlay.handleInput("\x1b");
|
|
1796
|
+
await opened;
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
// Issue #867: a completion settling while the parent agent run is active must
|
|
1800
|
+
// be held by the extension and flushed at the next turn boundary, not parked
|
|
1801
|
+
// in the host's followUp queue until the whole orchestrator run stops calling
|
|
1802
|
+
// tools.
|
|
1803
|
+
test("a background completion settling while the parent agent runs is delivered exactly once at the next turn end", async () => {
|
|
1804
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
1805
|
+
const harness = deps();
|
|
1806
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1807
|
+
const { ctx } = fakeContext();
|
|
1808
|
+
await fire("session_start", ctx);
|
|
1809
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Chained turns", mode: "background" }, undefined, undefined, ctx);
|
|
1810
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
1811
|
+
await tick();
|
|
1812
|
+
await fire("agent_start", ctx);
|
|
1813
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Chained done." }] }] });
|
|
1814
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
1815
|
+
await tick();
|
|
1816
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "nothing enters the conversation while the parent agent run is active");
|
|
1817
|
+
await fire("turn_end", ctx);
|
|
1818
|
+
const results = sent.filter((entry) => entry.message.customType === "gentle-agents.result");
|
|
1819
|
+
assert.equal(results.length, 1, "the held completion is delivered exactly once at turn_end");
|
|
1820
|
+
assert.match(String(results[0]!.message.content), new RegExp(`task ${id}, "Chained turns"`));
|
|
1821
|
+
// Pins the delivery mode against the host's drain semantics: "followUp" is
|
|
1822
|
+
// drained by the run loop only in its stop branch, so a parent that keeps
|
|
1823
|
+
// calling tools would see the completion when the whole run ends. "steer" is
|
|
1824
|
+
// polled every turn and injected before the next LLM call, bounding the wait
|
|
1825
|
+
// to the current turn.
|
|
1826
|
+
assert.deepEqual(results[0]!.options, { deliverAs: "steer", triggerTurn: true });
|
|
1827
|
+
await fire("turn_end", ctx);
|
|
1828
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 1, "a later turn_end never replays the completion");
|
|
1829
|
+
await fire("session_shutdown", ctx);
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test("a completion held past the stale window becomes transcript-only content and never re-enters the conversation", async () => {
|
|
1833
|
+
const { pi, tools, fire, sent, entries, entryRenderers } = fakePi();
|
|
1834
|
+
const harness = deps();
|
|
1835
|
+
let clock = 1000;
|
|
1836
|
+
harness.deps.now = () => clock;
|
|
1837
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1838
|
+
const { ctx } = fakeContext();
|
|
1839
|
+
await fire("session_start", ctx);
|
|
1840
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Slow orchestrator", mode: "background" }, undefined, undefined, ctx);
|
|
1841
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
1842
|
+
await tick();
|
|
1843
|
+
await fire("agent_start", ctx);
|
|
1844
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Late answer." }] }] });
|
|
1845
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
1846
|
+
await tick();
|
|
1847
|
+
clock += STALE_COMPLETION_MS + 1_000;
|
|
1848
|
+
await fire("turn_end", ctx);
|
|
1849
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a stale completion never enters the model context");
|
|
1850
|
+
const stale = entries.filter((entry) => entry.customType === "gentle-agents.stale-result");
|
|
1851
|
+
assert.equal(stale.length, 1, "the human still sees the stale completion as durable transcript content");
|
|
1852
|
+
assert.match(JSON.stringify(stale[0]!.data), new RegExp(id), "the stale notice names the task");
|
|
1853
|
+
const rendered = entryRenderers.get("gentle-agents.stale-result")!(stale[0]!, { expanded: true }, plainTheme).render(90).map(stripAnsi).join("\n");
|
|
1854
|
+
assert.match(rendered, /stale/i);
|
|
1855
|
+
assert.match(rendered, new RegExp(id));
|
|
1856
|
+
assert.match(rendered, /explore/);
|
|
1857
|
+
assert.match(rendered, /ago/);
|
|
1858
|
+
await fire("session_shutdown", ctx);
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
test("a completion the parent already pulled is dropped silently at the next turn end", async () => {
|
|
1862
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
1863
|
+
const harness = deps();
|
|
1864
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1865
|
+
const { ctx } = fakeContext();
|
|
1866
|
+
await fire("session_start", ctx);
|
|
1867
|
+
const started = await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Pulled early", mode: "background" }, undefined, undefined, ctx);
|
|
1868
|
+
const id = (started.details.gentleAgents as { taskId: string }).taskId;
|
|
1869
|
+
await tick();
|
|
1870
|
+
await fire("agent_start", ctx);
|
|
1871
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Pulled answer." }] }] });
|
|
1872
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
1873
|
+
await tick();
|
|
1874
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
1875
|
+
assert.match((await tools.get("subagent_result")!.execute("c2", { task_id: id }, undefined, undefined, ctx)).content[0].text, /Pulled answer\./);
|
|
1876
|
+
await fire("turn_end", ctx);
|
|
1877
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a consumed completion is dropped instead of replayed");
|
|
1878
|
+
await fire("session_shutdown", ctx);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
test("a session restart never replays a completion still pending from before it", async () => {
|
|
1882
|
+
const { pi, tools, fire, sent } = fakePi();
|
|
1883
|
+
const harness = deps();
|
|
1884
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1885
|
+
const { ctx } = fakeContext();
|
|
1886
|
+
await fire("session_start", ctx);
|
|
1887
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Restarted", mode: "background" }, undefined, undefined, ctx);
|
|
1888
|
+
await tick();
|
|
1889
|
+
await fire("agent_start", ctx);
|
|
1890
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Unclaimed answer." }] }] });
|
|
1891
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
1892
|
+
await tick();
|
|
1893
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0);
|
|
1894
|
+
await fire("session_start", ctx, { reason: "resume" });
|
|
1895
|
+
await fire("turn_end", ctx);
|
|
1896
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "a resumed session starts with an empty completion queue");
|
|
1897
|
+
await fire("session_shutdown", ctx);
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
test("a background completion owned by a prior session is dropped, never delivered into the current session", async () => {
|
|
1901
|
+
const { pi, tools, fire, sent, entries } = fakePi();
|
|
1902
|
+
const harness = deps();
|
|
1903
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1904
|
+
const { ctx } = fakeContext();
|
|
1905
|
+
await fire("session_start", ctx);
|
|
1906
|
+
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Cross session", mode: "background" }, undefined, undefined, ctx);
|
|
1907
|
+
await tick();
|
|
1908
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => "s2";
|
|
1909
|
+
await fire("session_start", ctx, { type: "session_start", reason: "new" });
|
|
1910
|
+
harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Cross answer." }] }] });
|
|
1911
|
+
harness.children[0].emit({ type: "agent_settled" });
|
|
1912
|
+
await tick();
|
|
1913
|
+
await fire("turn_end", ctx);
|
|
1914
|
+
assert.equal(sent.filter((entry) => entry.message.customType === "gentle-agents.result").length, 0, "the replacement session receives no completion it does not own");
|
|
1915
|
+
assert.equal(entries.filter((entry) => entry.customType === "gentle-agents.stale-result").length, 0);
|
|
1916
|
+
await fire("session_shutdown", ctx);
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
test("aborting the caller's signal cancels the subagent, records it, and says why", async () => {
|
|
1920
|
+
const { pi, tools, fire } = fakePi();
|
|
1921
|
+
const harness = deps();
|
|
1922
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1923
|
+
const { ctx, dialogs } = fakeContext();
|
|
1924
|
+
await fire("session_start", ctx);
|
|
1925
|
+
const controller = new AbortController();
|
|
1926
|
+
const pending = tools.get("subagent_run")!.execute("abort", { agent: "explore", task: "keep working" }, controller.signal, undefined, ctx);
|
|
1927
|
+
await tick();
|
|
1928
|
+
controller.abort();
|
|
1929
|
+
await tick();
|
|
1930
|
+
const yielded = await pending;
|
|
1931
|
+
const details = (yielded.details as { gentleAgents?: { taskId: string; status: string } }).gentleAgents!;
|
|
1932
|
+
assert.equal(details.status, "cancelled", "the run is recorded as cancelled");
|
|
1933
|
+
assert.match((yielded as { content: Array<{ text: string }> }).content[0].text, /cancelled/);
|
|
1934
|
+
assert.ok(
|
|
1935
|
+
dialogs.some((entry) => entry.startsWith("notify:") && /cancelled/.test(entry) && /tool call was aborted/.test(entry)),
|
|
1936
|
+
"a warning names the abort and the cancellation",
|
|
1937
|
+
);
|
|
1938
|
+
assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
test("selected child routes recheck provenance and keep separately authorized local tools", () => {
|
|
1942
|
+
const names = ["fetch_content", "web_search", "read", "write", "mem_save", "subagent_parent_message"];
|
|
1943
|
+
const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
|
|
1944
|
+
const path = join(root, "openspec/changes/demo/research.md");
|
|
1945
|
+
const scope = { store: "both", worktree: root, changeName: "demo", retainedIntent: "docs", locators: [{ artifact: "research", path, revision: 1, digest: "a".repeat(64), engram: { id: 1, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } }] };
|
|
1946
|
+
for (const mismatch of ["none", "path", "sdk", "inactive", "unregistered", "restriction"]) {
|
|
1947
|
+
const hooks = new Map<string, (event: { toolName?: string; systemPrompt?: string; input?: object }, ctx?: { cwd: string }) => { block?: boolean; systemPrompt?: string } | undefined>();
|
|
1948
|
+
const active = names.filter(name => mismatch !== "inactive" || name !== "fetch_content");
|
|
1949
|
+
const registered = names.filter(name => mismatch !== "unregistered" || name !== "fetch_content");
|
|
1950
|
+
const pi = { on: (name: string, hook: typeof hooks extends Map<string, infer H> ? H : never) => hooks.set(name, hook), getActiveTools: () => active,
|
|
1951
|
+
getAllTools: () => registered.map(name => ({ name, sourceInfo: { source: mismatch === "sdk" && name === "fetch_content" ? "sdk" : "extension", path: mismatch === "path" ? "/other.ts" : "/installed/web.ts" } })) };
|
|
1952
|
+
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) });
|
|
1953
|
+
const call = hooks.get("tool_call")!;
|
|
1954
|
+
assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
|
|
1955
|
+
assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
|
|
1956
|
+
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, ["write", "mem_save"].includes(toolName) ? true : undefined, toolName);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
test("research child narrows artifact arguments and observes actual dual-store readbacks", async () => {
|
|
1961
|
+
const { createHash } = await import("node:crypto");
|
|
1962
|
+
const cwd = join(root, "bounded-child");
|
|
1963
|
+
mkdirSync(cwd, { recursive: true });
|
|
1964
|
+
const bytes = '{"revision":1,"outcome":"blocked"}';
|
|
1965
|
+
const locator = { artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: createHash("sha256").update(bytes).digest("hex"), engram: { id: 12, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } };
|
|
1966
|
+
const scope = { store: "both", worktree: cwd, changeName: "demo", retainedIntent: "fetch missing; preserve questions", locators: [locator] };
|
|
1967
|
+
const hooks = new Map<string, (...args: unknown[]) => unknown>();
|
|
1968
|
+
let active = ["read", "write", "mem_get_observation", "mem_save", "subagent_parent_message"];
|
|
1969
|
+
const journal = join(cwd, "session.jsonl"); writeFileSync(journal, "");
|
|
1970
|
+
const pi = { appendEntry: (customType, data) => appendFileSync(journal, JSON.stringify({ type: "custom", customType, data }) + "\n"), on: (name: string, fn: (...args: unknown[]) => unknown) => hooks.set(name, fn), getActiveTools: () => active, getAllTools: () => active.map(name => ({ name })) };
|
|
1971
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
1972
|
+
const ctx = { cwd, sessionManager: { getEntries: () => [], getSessionFile: () => journal } };
|
|
1973
|
+
const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }, ctx) as { systemPrompt: string };
|
|
1974
|
+
assert.match(prompt.systemPrompt, /retainedIntent/);
|
|
1975
|
+
assert.match(prompt.systemPrompt, /never authority/);
|
|
1976
|
+
const call = (toolName: string, input: object, toolCallId = "c") => hooks.get("tool_call")!({ toolName, input, toolCallId }, ctx) as { block: boolean } | undefined;
|
|
1977
|
+
const result = (toolName: string, input: object, content: string, isError = false) => hooks.get("tool_result")!({ toolName, input, toolCallId: "c", content: [{ type: "text", text: content }], isError }, ctx) as { content: { text: string }[]; isError?: boolean };
|
|
1978
|
+
assert.equal(call("write", { path: locator.path, content: '{"revision":2,"outcome":"blocked"}' })?.block, true, "initial readback must precede mutation");
|
|
1979
|
+
assert.equal(call("write", { path: join(cwd, "outside.md") })?.block, true);
|
|
1980
|
+
assert.equal(call("mem_get_observation", { id: 13 })?.block, true);
|
|
1981
|
+
assert.equal(call("mem_save", { project: "pi", topic_key: "sdd/other/research" })?.block, true);
|
|
1982
|
+
assert.equal(call("read", { path: locator.path }), undefined);
|
|
1983
|
+
assert.match(result("read", { path: locator.path }, bytes).content.at(-1)!.text, /incomplete/);
|
|
1984
|
+
assert.equal(call("mem_get_observation", { id: 12 }), undefined);
|
|
1985
|
+
const observed = { ...locator.engram, content: bytes };
|
|
1986
|
+
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify(observed)).content.at(-1)!.text, /all selected stores/);
|
|
1987
|
+
hooks.get("before_agent_start")!({ systemPrompt: "fresh generation" }, ctx);
|
|
1988
|
+
assert.equal(call("write", { path: locator.path, content: '{"revision":5}' })?.block, true, "new generation cannot reuse initial authorization");
|
|
1989
|
+
call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
|
|
1990
|
+
call("mem_get_observation", { id: 12 }); result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
|
|
1991
|
+
const next = '{"revision":5,"outcome":"partial"}';
|
|
1992
|
+
assert.equal(call("write", { path: locator.path, content: next }), undefined);
|
|
1993
|
+
call("read", { path: locator.path }, "pending-read");
|
|
1994
|
+
const pendingRead = hooks.get("tool_result")!({ toolName: "read", input: { path: locator.path }, toolCallId: "pending-read", content: [{ type: "text", text: bytes }], isError: false }, ctx) as { content: { text: string }[] };
|
|
1995
|
+
assert.match(pendingRead.content.at(-1)!.text, /incomplete/, "old bytes cannot complete a pending mutation");
|
|
1996
|
+
result("write", { path: locator.path, content: next }, "written");
|
|
1997
|
+
call("read", { path: locator.path });
|
|
1998
|
+
assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /incomplete/);
|
|
1999
|
+
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }), undefined);
|
|
2000
|
+
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }, "saved");
|
|
2001
|
+
call("read", { path: locator.path });
|
|
2002
|
+
result("read", { path: locator.path }, next);
|
|
2003
|
+
call("mem_get_observation", { id: 12 });
|
|
2004
|
+
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /all selected stores/);
|
|
2005
|
+
assert.equal(call("write", { path: locator.path, content: '{"revision":2}' })?.block, true, "revision 1 to 5 to 2 is refused");
|
|
2006
|
+
const newer = '{"revision":6}';
|
|
2007
|
+
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }), undefined);
|
|
2008
|
+
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }, "saved");
|
|
2009
|
+
call("mem_get_observation", { id: 12 });
|
|
2010
|
+
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: newer, revision_count: 2 })).isError, true, "each save must advance the accepted Engram revision count");
|
|
2011
|
+
call("write", { path: locator.path, content: '{"revision":3,"outcome":"partial"}' });
|
|
2012
|
+
result("write", { path: locator.path }, "permission denied", true);
|
|
2013
|
+
call("mem_get_observation", { id: 12 });
|
|
2014
|
+
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /proposal_ready=false/, "write attempt invalidates prior readback even when denied");
|
|
2015
|
+
call("mem_get_observation", { id: 12 });
|
|
2016
|
+
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, project: "wrong" })).isError, true);
|
|
2017
|
+
assert.equal(call("write", { path: locator.path, content: '{"revision":4}' })?.block, true, "stale/divergent readback must refuse recovery writes");
|
|
2018
|
+
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: '{"revision":4}' })?.block, true);
|
|
2019
|
+
active = active.filter(name => name !== "write");
|
|
2020
|
+
assert.equal(call("write", { path: locator.path })?.block, true);
|
|
2021
|
+
assert.equal((hooks.get("tool_call")!({ toolName: "read", input: { path: locator.path } }, { cwd: root }) as { block: boolean }).block, true);
|
|
2022
|
+
active.push("write");
|
|
2023
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
2024
|
+
call("read", { path: locator.path });
|
|
2025
|
+
result("read", { path: locator.path }, bytes);
|
|
2026
|
+
call("mem_get_observation", { id: 12 });
|
|
2027
|
+
result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
|
|
2028
|
+
call("write", { path: locator.path, content: next });
|
|
2029
|
+
result("write", { path: locator.path, content: next }, "written");
|
|
2030
|
+
const divergent = '{"revision":2,"outcome":"done"}';
|
|
2031
|
+
call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent });
|
|
2032
|
+
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent }, "saved");
|
|
2033
|
+
call("read", { path: locator.path });
|
|
2034
|
+
result("read", { path: locator.path }, next);
|
|
2035
|
+
call("mem_get_observation", { id: 12 });
|
|
2036
|
+
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: divergent, revision_count: 2 })).isError, true, "individually matching but divergent hybrid writes never converge");
|
|
2037
|
+
for (const completion of [[], [{ type: "text", text: "" }], [{ type: "text", text: "denied" }]]) {
|
|
2038
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: "openspec", locators: [{ ...locator, engram: undefined }] }) });
|
|
2039
|
+
call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
|
|
2040
|
+
assert.equal(call("write", { path: locator.path, content: next }), undefined);
|
|
2041
|
+
assert.equal(call("write", { path: locator.path, content: next }, "overlap")?.block, true);
|
|
2042
|
+
hooks.get("tool_result")!({ toolName: "write", input: { path: locator.path, content: next }, toolCallId: "c", content: completion, isError: completion.length > 0 && completion[0].text === "denied" }, ctx);
|
|
2043
|
+
call("read", { path: locator.path });
|
|
2044
|
+
assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /proposal_ready=false/, "failed or malformed write cannot establish completion");
|
|
2045
|
+
assert.equal(call("write", { path: locator.path, content: '{"revision":6}' })?.block, true);
|
|
2046
|
+
}
|
|
2047
|
+
for (const tool of ["write", "mem_save"]) {
|
|
2048
|
+
const memory = tool === "mem_save", readTool = memory ? "mem_get_observation" : "read";
|
|
2049
|
+
const input = memory ? { id: 12 } : { path: locator.path };
|
|
2050
|
+
const mutation = memory ? { project: "pi", topic_key: locator.engram.topic_key, content: next } : { path: locator.path, content: next };
|
|
2051
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: memory ? "engram" : "openspec", locators: [{ ...locator, path: memory ? undefined : locator.path, engram: memory ? locator.engram : undefined }] }) });
|
|
2052
|
+
call(readTool, input); result(readTool, input, memory ? JSON.stringify(observed) : bytes);
|
|
2053
|
+
assert.equal(call(tool, mutation), undefined);
|
|
2054
|
+
assert.doesNotThrow(() => hooks.get("tool_result")!({ toolName: tool, input: mutation, toolCallId: "c", content: [null], isError: false }, ctx));
|
|
2055
|
+
call(readTool, input);
|
|
2056
|
+
assert.match(result(readTool, input, memory ? JSON.stringify({ ...observed, content: next, revision_count: 2 }) : next).content.at(-1)!.text, /proposal_ready=false/);
|
|
2057
|
+
assert.equal(call(tool, { ...mutation, content: '{"revision":6}' })?.block, true);
|
|
2058
|
+
}
|
|
2059
|
+
for (const store of ["openspec", "engram", "both"]) {
|
|
2060
|
+
for (const bad of ["missing", "malformed", "revision", "digest", "project", "topic", "id", "worktree"]) {
|
|
2061
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store, locators: [{ ...locator, path: store === "engram" ? undefined : locator.path, engram: store === "openspec" ? undefined : locator.engram }] }) });
|
|
2062
|
+
const memory = store !== "openspec";
|
|
2063
|
+
const tool = memory ? "mem_get_observation" : "read";
|
|
2064
|
+
const input = memory ? { id: 12 } : { path: locator.path };
|
|
2065
|
+
if (store === "both") {
|
|
2066
|
+
call("read", { path: locator.path });
|
|
2067
|
+
result("read", { path: locator.path }, bytes);
|
|
2068
|
+
assert.equal(call("write", { path: locator.path, content: next })?.block, true, "both initial stores must match before either mutation");
|
|
2069
|
+
}
|
|
2070
|
+
if (bad !== "missing") {
|
|
2071
|
+
const content = bad === "revision" ? '{"revision":0}' : bad === "digest" ? '{"revision":1,"different":true}' : bytes;
|
|
2072
|
+
const value = { ...observed, content, ...(bad === "project" ? { project: "wrong" } : bad === "topic" ? { topic_key: "wrong" } : bad === "id" ? { id: 13 } : {}) };
|
|
2073
|
+
if (bad === "worktree") {
|
|
2074
|
+
assert.equal((hooks.get("tool_call")!({ toolName: tool, input, toolCallId: "c" }, { cwd: root }) as { block: boolean }).block, true);
|
|
2075
|
+
} else if (memory || !["project", "topic", "id"].includes(bad)) {
|
|
2076
|
+
call(tool, input);
|
|
2077
|
+
assert.equal(result(tool, input, bad === "malformed" ? "{" : memory ? JSON.stringify(value) : content).isError, true);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
if (store !== "engram") assert.equal(call("write", { path: locator.path, content: next })?.block, true, `${store}/${bad}: zero writes`);
|
|
2081
|
+
if (memory) assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next })?.block, true, `${store}/${bad}: zero saves`);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
test("managed remediation acquires before spawn and finalizes failure without verifier success", async () => {
|
|
2089
|
+
const h = fakePi(), runtime = deps(), fixtureHome = join(root, "remediation-home");
|
|
2090
|
+
let retainedId: string;
|
|
2091
|
+
const spawn = runtime.deps.spawn;
|
|
2092
|
+
runtime.deps.spawn = (...args) => {
|
|
2093
|
+
const retained = JSON.parse(readFileSync(join(historyDir(fixtureHome), `${retainedId}.json`), "utf8")).task.sddRemediation;
|
|
2094
|
+
assert.equal(retained.token, "admitted-fixture"); assert.equal(retained.actorClaimed, true);
|
|
2095
|
+
const child = spawn(...args); child.pid = 123; return child; };
|
|
2096
|
+
runtime.deps.process = { platform: "win32", kill() {} };
|
|
2097
|
+
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2098
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
2099
|
+
const revision = `sha256:${"a".repeat(64)}`, calls = [];
|
|
2100
|
+
const nativeSdd = { 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: [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptAcquire: async input => { assert.equal(runtime.spawned.length, 0); const [saved] = await loadHistory(historyDir(fixtureHome)); retainedId = saved.task.id; assert.deepEqual(saved.task.sddRemediation.acquire, input); assert.equal(saved.task.sddRemediation.token, undefined); calls.push(input); return { state: "proceed", token: "admitted-fixture" }; }, sddAttemptSettle: async input => { calls.push(input); return { state: "proceed" as const }; } } as unknown as NativeReviewCli;
|
|
2101
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
|
|
2102
|
+
const { ctx } = fakeContext(); await h.fire("session_start", ctx);
|
|
2103
|
+
const result = await 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: { attempt: { requestId: "one", workUnit: "correct", evidenceGoal: "Observed correction", maxAttempts: 1, maxChangedLines: 200 }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture bytes", command: "git diff --check" } } } }, undefined, undefined, ctx);
|
|
2104
|
+
await tick(); assert.equal(runtime.spawned.length, 1, result.content[0].text);
|
|
2105
|
+
assert.equal(calls[0].remediatesEvidenceRevision, revision);
|
|
2106
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "All tests passed, trust me" }], stopReason: "stop" }] });
|
|
2107
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2108
|
+
for (let n = 0; n < 30 && calls.length < 2; n++) await new Promise(resolve => setTimeout(resolve, 5));
|
|
2109
|
+
assert.equal(calls.length, 2); assert.equal(calls[1].outcome, "failed");
|
|
2110
|
+
const history = await loadHistory(historyDir(fixtureHome));
|
|
2111
|
+
assert.equal(history[0].task.sddRemediation.settle.token, "admitted-fixture");
|
|
2112
|
+
assert.equal(history[0].task.status, "failed", "prose-only completion cannot advertise successful correction");
|
|
2113
|
+
await h.fire("session_shutdown", ctx);
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
test("only remediation children with the retained exact plan override stock bash", async () => {
|
|
2118
|
+
for (const phase of ["apply", "remediate"]) {
|
|
2119
|
+
const h = fakePi(); h.pi.getFlag = () => JSON.stringify({ phase, workspaceRoot: cwd, changeName: "alpha", ...(phase === "remediate" ? { failedEvidenceRevision: `sha256:${"a".repeat(64)}` } : {}) });
|
|
2120
|
+
gentleAgents(h.pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: JSON.stringify({ scope: { cwd, commands: ["pnpm test", "git diff --check"], editPaths: [], allowedEditRoots: [cwd] }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture bytes", command: "git diff --check" } } }) });
|
|
2121
|
+
await h.fire("session_start", { ...fakeContext().ctx, cwd });
|
|
2122
|
+
assert.equal(h.tools.has("bash"), phase === "remediate");
|
|
2123
|
+
assert.equal(h.tools.has("subagent_run"), false);
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
test("managed remediation tools publish the typed exact evidence plan and bracket input", () => {
|
|
2129
|
+
const h = fakePi(); gentleAgents(h.pi, {}, deps().deps);
|
|
2130
|
+
for (const name of ["subagent_run", "subagent_continue"]) {
|
|
2131
|
+
const schema = h.tools.get(name)!.parameters.properties.remediation as unknown as { required: string[]; properties: { plan: { required: string[] }; attempt: { properties: { token: unknown } } } };
|
|
2132
|
+
assert.deepEqual(schema.required, ["plan", "attempt"]);
|
|
2133
|
+
assert.deepEqual(schema.properties.plan.required, ["cwd", "commands", "runtimeHarness", "rollback"]);
|
|
2134
|
+
assert.ok(schema.properties.attempt.properties.token);
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
test("R1 malformed child grant denies tools even before/after failed session initialization", () => {
|
|
2140
|
+
const hooks = new Map(), registered = [];
|
|
2141
|
+
const pi = { on: (name, fn) => hooks.set(name, fn), registerTool: tool => registered.push(tool), getFlag: () => "{}" };
|
|
2142
|
+
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: "malformed" });
|
|
2143
|
+
const denied = () => hooks.get("tool_call")?.({ toolName: "bash", input: { command: "touch outside" } }, { cwd })?.block;
|
|
2144
|
+
assert.equal(denied(), true);
|
|
2145
|
+
assert.doesNotThrow(() => hooks.get("session_start")({}, { cwd }));
|
|
2146
|
+
assert.equal(denied(), true); assert.equal(registered.length, 0);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
test("R3/R4 host reload refuses retained acquire/actor uncertainty without another launch", async () => {
|
|
2151
|
+
for (const actorClaimed of [false, true, "blocked", "complete"]) {
|
|
2152
|
+
const normal = typeof actorClaimed === "string";
|
|
2153
|
+
const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-reload-${actorClaimed}`);
|
|
2154
|
+
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2155
|
+
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
2156
|
+
const revision = `sha256:${"a".repeat(64)}`;
|
|
2157
|
+
const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
|
|
2158
|
+
await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: normal ? false : !actorClaimed, actorClaimed: normal ? false : actorClaimed, ...(normal ? { acquireResult: { state: actorClaimed } } : actorClaimed ? { token: "retained-token" } : {}) } } as never, emptyThread());
|
|
2159
|
+
let acquisitions = 0, confirmations = 0;
|
|
2160
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddStatus: async () => { throw new Error("fresh status reached"); }, sddAttemptSettle: async () => ({ state: "proceed" as const }), sddAttemptAcquire: async () => { acquisitions++; return { state: "proceed", token: "unsafe" }; } } as unknown as NativeReviewCli });
|
|
2161
|
+
const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
|
|
2162
|
+
await h.fire("session_start", ctx);
|
|
2163
|
+
await assert.rejects(h.tools.get("subagent_run").execute("again", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { attempt: { ...acquire, requestId: "different" }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture", command: "git diff --check" } } } }, undefined, undefined, ctx), normal ? /fresh status reached/ : /reconcile exact history without actor replay/);
|
|
2164
|
+
assert.equal(acquisitions, 0); assert.equal(confirmations, 0); assert.equal(runtime.spawned.length, 0);
|
|
2165
|
+
assert.deepEqual((await loadHistory(historyDir(fixtureHome)))[0].task.sddRemediation.acquire, acquire);
|
|
2166
|
+
await h.fire("session_shutdown", ctx);
|
|
2167
|
+
}
|
|
2168
|
+
});
|