pi-herdr-subagents 0.1.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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Integration tests for the full subagent lifecycle.
3
+ *
4
+ * These tests spawn real pi sessions with real LLM calls.
5
+ * Each test creates a herdr pane, runs pi with a task that uses the subagent
6
+ * tool, and verifies the outcome through marker files and terminal output.
7
+ *
8
+ * Duration: ~30-120s per test, depending on the selected model.
9
+ *
10
+ * Run `npm run test:integration` from inside herdr.
11
+ *
12
+ * Configuration:
13
+ * PI_TEST_MODEL — model for all pi sessions (default: openrouter/free)
14
+ * PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
15
+ */
16
+ import { describe, it, before, after } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
19
+ import {
20
+ getAvailableBackends,
21
+ setBackend,
22
+ restoreBackend,
23
+ createTestEnv,
24
+ cleanupTestEnv,
25
+ createTrackedSurface,
26
+ startPi,
27
+ waitForScreen,
28
+ waitForFile,
29
+ sleep,
30
+ uniqueId,
31
+ trackTempFile,
32
+ readPane,
33
+ PI_TIMEOUT,
34
+ type TestEnv,
35
+ } from "./harness.ts";
36
+
37
+ const backends = getAvailableBackends();
38
+
39
+ if (backends.length === 0) {
40
+ console.log("⚠️ herdr is unavailable — skipping subagent lifecycle integration tests");
41
+ console.log(" Run inside herdr to enable these tests.");
42
+ }
43
+
44
+ for (const backend of backends) {
45
+ describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
46
+ let prevMux: string | undefined;
47
+ let env: TestEnv;
48
+
49
+ before(() => {
50
+ prevMux = setBackend(backend);
51
+ env = createTestEnv(backend);
52
+ });
53
+
54
+ after(() => {
55
+ cleanupTestEnv(env);
56
+ restoreBackend(prevMux);
57
+ });
58
+
59
+ // ── Basic spawn + completion ──
60
+
61
+ it("spawns a subagent that writes a file and verifies the session", async () => {
62
+ const id = uniqueId();
63
+ const markerFile = `/tmp/pi-integ-echo-${id}.txt`;
64
+ trackTempFile(env, markerFile);
65
+
66
+ const surface = createTrackedSurface(env, `echo-${id}`);
67
+ await sleep(1000);
68
+
69
+ const task = [
70
+ `Call the subagent tool with these EXACT parameters:`,
71
+ ` name: "Echo-${id}"`,
72
+ ` agent: "test-echo"`,
73
+ ` task: "Run this bash command: echo 'PASS_${id}' > '${markerFile}'"`,
74
+ `Do not do anything else. Just call the subagent tool once.`,
75
+ `After you receive the subagent result, say INTEGRATION_COMPLETE.`,
76
+ ].join("\n");
77
+
78
+ startPi(surface, env.dir, task);
79
+
80
+ // Verify: subagent created the marker file
81
+ const content = await waitForFile(markerFile, PI_TIMEOUT, /PASS/);
82
+ assert.ok(
83
+ content.includes(`PASS_${id}`),
84
+ `Marker file should contain PASS_${id}. Got: ${content.trim()}`,
85
+ );
86
+
87
+ // Verify: outer pi received the subagent result
88
+ const screen = await waitForScreen(
89
+ surface,
90
+ /INTEGRATION_COMPLETE|completed|Sub-agent.*"Echo/i,
91
+ PI_TIMEOUT,
92
+ );
93
+
94
+ // Verify: session file was created (shown in steer result)
95
+ const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
96
+ if (sessionMatch) {
97
+ const sessionFile = sessionMatch[1];
98
+ assert.ok(existsSync(sessionFile), `Subagent session file should exist: ${sessionFile}`);
99
+
100
+ const lines = readFileSync(sessionFile, "utf8").trim().split("\n");
101
+ assert.ok(lines.length >= 2, `Session should have ≥2 entries, got ${lines.length}`);
102
+
103
+ const header = JSON.parse(lines[0]);
104
+ assert.equal(header.type, "session", "First entry should be session header");
105
+ assert.ok(header.id, "Session header should have an id");
106
+ }
107
+ });
108
+
109
+ // ── In-progress activity snapshots ──
110
+
111
+ it("keeps a long active tool call from surfacing false stalled status", async () => {
112
+ const id = uniqueId();
113
+ const startFile = `/tmp/pi-integ-status-start-${id}.txt`;
114
+ const markerFile = `/tmp/pi-integ-status-${id}.txt`;
115
+ trackTempFile(env, startFile);
116
+ trackTempFile(env, markerFile);
117
+
118
+ const surface = createTrackedSurface(env, `status-${id}`);
119
+ await sleep(1000);
120
+
121
+ const task = [
122
+ `Call the subagent tool with these EXACT parameters:`,
123
+ ` name: "Status-${id}"`,
124
+ ` agent: "test-echo"`,
125
+ ` task: "Run this bash command: echo 'START_${id}' > '${startFile}'; sleep 90; echo 'STATUS_${id}' > '${markerFile}'"`,
126
+ `Do not do anything else. Just call the subagent tool once.`,
127
+ `After you receive the subagent result, say STATUS_TEST_DONE.`,
128
+ ].join("\n");
129
+
130
+ startPi(surface, env.dir, task);
131
+
132
+ const activeScreen = await waitForScreen(surface, /active[\s\S]*bash|bash[\s\S]*active/i, PI_TIMEOUT, 300);
133
+ assert.doesNotMatch(activeScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
134
+
135
+ await waitForFile(startFile, PI_TIMEOUT, /START_/);
136
+ assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the long sleep");
137
+ await sleep(65_000);
138
+ assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the watchdog assertion");
139
+ const watchdogScreen = readPane(surface, 300);
140
+ assert.doesNotMatch(watchdogScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
141
+
142
+ const content = await waitForFile(markerFile, PI_TIMEOUT, /STATUS_/);
143
+ assert.ok(content.includes(`STATUS_${id}`), `Marker file should contain STATUS_${id}`);
144
+
145
+ const completionScreen = await waitForScreen(
146
+ surface,
147
+ /STATUS_TEST_DONE|completed|Sub-agent.*"Status-/i,
148
+ PI_TIMEOUT,
149
+ 300,
150
+ );
151
+ assert.ok(/STATUS_TEST_DONE|completed/i.test(completionScreen));
152
+ });
153
+
154
+ // ── Parallel subagent spawn ──
155
+
156
+ it("spawns two subagents in parallel and both complete", async () => {
157
+ const id = uniqueId();
158
+ const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
159
+ const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
160
+ trackTempFile(env, fileA);
161
+ trackTempFile(env, fileB);
162
+
163
+ const surface = createTrackedSurface(env, `parallel-${id}`);
164
+ await sleep(1000);
165
+
166
+ const task = [
167
+ `You must call the subagent tool TWICE. Make both calls before waiting for results.`,
168
+ ``,
169
+ `First call:`,
170
+ ` name: "ParaA-${id}"`,
171
+ ` agent: "test-echo"`,
172
+ ` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
173
+ ``,
174
+ `Second call:`,
175
+ ` name: "ParaB-${id}"`,
176
+ ` agent: "test-echo"`,
177
+ ` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
178
+ ``,
179
+ `Call both subagent tools NOW, do not wait between them.`,
180
+ ].join("\n");
181
+
182
+ startPi(surface, env.dir, task);
183
+
184
+ // Both marker files should appear
185
+ const [contentA, contentB] = await Promise.all([
186
+ waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
187
+ waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
188
+ ]);
189
+
190
+ assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
191
+ assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
192
+ });
193
+
194
+ // ── Fork mode ──
195
+
196
+ it("fork mode creates a child session linked to the parent", async () => {
197
+ const id = uniqueId();
198
+ const markerFile = `/tmp/pi-integ-fork-${id}.txt`;
199
+ trackTempFile(env, markerFile);
200
+
201
+ const surface = createTrackedSurface(env, `fork-${id}`);
202
+ await sleep(1000);
203
+
204
+ const task = [
205
+ `Call the subagent tool with these EXACT parameters:`,
206
+ ` name: "Fork-${id}"`,
207
+ ` fork: true`,
208
+ ` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
209
+ `Do not set the agent parameter. Just set name, fork, and task.`,
210
+ `After you receive the result, say FORK_COMPLETE.`,
211
+ ].join("\n");
212
+
213
+ startPi(surface, env.dir, task);
214
+
215
+ // Verify: forked subagent created the file
216
+ const content = await waitForFile(markerFile, PI_TIMEOUT, /FORK_OK/);
217
+ assert.ok(content.includes(`FORK_OK_${id}`), `Fork marker file should exist with content`);
218
+
219
+ // Wait for the outer pi to show the result
220
+ const screen = await waitForScreen(
221
+ surface,
222
+ /FORK_COMPLETE|completed|Sub-agent.*"Fork/i,
223
+ PI_TIMEOUT,
224
+ );
225
+
226
+ // Verify: the forked session has a parent link
227
+ const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
228
+ if (sessionMatch) {
229
+ const sessionFile = sessionMatch[1];
230
+ assert.ok(existsSync(sessionFile), `Fork session file should exist: ${sessionFile}`);
231
+
232
+ const entries = readFileSync(sessionFile, "utf8")
233
+ .trim()
234
+ .split("\n")
235
+ .map((l) => JSON.parse(l));
236
+ const header = entries[0];
237
+ assert.equal(header.type, "session", "First entry should be session header");
238
+ assert.ok(header.parentSession, "Fork session should have parentSession field");
239
+ // Fork sessions include parent context (model_change entries etc.)
240
+ assert.ok(entries.length >= 2, "Fork session should have context entries beyond header");
241
+ }
242
+ });
243
+
244
+ // ── caller_ping ──
245
+
246
+ it("subagent caller_ping sends notification back to the parent", async () => {
247
+ const id = uniqueId();
248
+
249
+ const surface = createTrackedSurface(env, `ping-${id}`);
250
+ await sleep(1000);
251
+
252
+ const task = [
253
+ `Call the subagent tool with these EXACT parameters:`,
254
+ ` name: "Ping-${id}"`,
255
+ ` agent: "test-ping"`,
256
+ ` task: "PING_TEST_${id}"`,
257
+ `Just call the subagent tool once. Do not do anything else before calling it.`,
258
+ ].join("\n");
259
+
260
+ startPi(surface, env.dir, task);
261
+
262
+ // The test-ping agent calls caller_ping, which steers a "needs help" message
263
+ // back to the outer pi. Look for it on screen.
264
+ const screen = await waitForScreen(
265
+ surface,
266
+ /needs help|PING|caller_ping|ping/i,
267
+ PI_TIMEOUT,
268
+ );
269
+
270
+ assert.ok(
271
+ /needs help|PING/i.test(screen),
272
+ `Screen should show ping notification. Got:\n${screen.slice(-800)}`,
273
+ );
274
+ });
275
+
276
+ // ── Agent discovery ──
277
+
278
+ it("subagent discovers project-local test agents", async () => {
279
+ const id = uniqueId();
280
+ const markerFile = `/tmp/pi-integ-discovery-${id}.txt`;
281
+ trackTempFile(env, markerFile);
282
+
283
+ const surface = createTrackedSurface(env, `discovery-${id}`);
284
+ await sleep(1000);
285
+
286
+ // Use subagents_list to verify test agents are discoverable,
287
+ // then spawn one to prove it works end-to-end.
288
+ const task = [
289
+ `First, call the subagents_list tool to see available agents.`,
290
+ `Then call the subagent tool:`,
291
+ ` name: "Disco-${id}"`,
292
+ ` agent: "test-echo"`,
293
+ ` task: "Run: echo 'DISCO_${id}' > '${markerFile}'"`,
294
+ `After you receive the subagent result, say DISCOVERY_DONE.`,
295
+ ].join("\n");
296
+
297
+ startPi(surface, env.dir, task);
298
+
299
+ // The test-echo agent (discovered from project .pi/agents/) should work
300
+ const content = await waitForFile(markerFile, PI_TIMEOUT, /DISCO/);
301
+ assert.ok(content.includes(`DISCO_${id}`), `Discovery test marker should exist`);
302
+ });
303
+
304
+ // ── Subagent with custom system prompt ──
305
+
306
+ it("passes systemPrompt to subagent", async () => {
307
+ const id = uniqueId();
308
+ const markerFile = `/tmp/pi-integ-sysprompt-${id}.txt`;
309
+ trackTempFile(env, markerFile);
310
+
311
+ const surface = createTrackedSurface(env, `sysprompt-${id}`);
312
+ await sleep(1000);
313
+
314
+ const task = [
315
+ `Call the subagent tool with these parameters:`,
316
+ ` name: "SysP-${id}"`,
317
+ ` agent: "test-echo"`,
318
+ ` systemPrompt: "Always start your response with CUSTOM_PROMPT_ACTIVE."`,
319
+ ` task: "Write 'SYSPROMPT_${id}' to ${markerFile} using bash: echo 'SYSPROMPT_${id}' > '${markerFile}'"`,
320
+ `After the subagent completes, say SYSPROMPT_TEST_DONE.`,
321
+ ].join("\n");
322
+
323
+ startPi(surface, env.dir, task);
324
+
325
+ const content = await waitForFile(markerFile, PI_TIMEOUT, /SYSPROMPT/);
326
+ assert.ok(content.includes(`SYSPROMPT_${id}`), `System prompt test marker should exist`);
327
+ });
328
+ });
329
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Smoke tests for the systemPromptMode feature.
3
+ * Tests: frontmatter parsing, identity routing, CLI flag generation.
4
+ */
5
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ let passed = 0;
10
+ let failed = 0;
11
+
12
+ function assert(condition: boolean, msg: string) {
13
+ if (condition) {
14
+ console.log(` ✅ ${msg}`);
15
+ passed++;
16
+ } else {
17
+ console.log(` ❌ ${msg}`);
18
+ failed++;
19
+ }
20
+ }
21
+
22
+ // --- Extracted logic under test ---
23
+
24
+ function parseFrontmatter(content: string) {
25
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
26
+ if (!match) return null;
27
+ const frontmatter = match[1];
28
+ const get = (key: string) => {
29
+ const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
30
+ return m ? m[1].trim() : undefined;
31
+ };
32
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
33
+ const spm = get("system-prompt");
34
+ return {
35
+ systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
36
+ body: body || undefined,
37
+ };
38
+ }
39
+
40
+ function simulateRouting(
41
+ agentBody: string | undefined,
42
+ systemPromptMode: "append" | "replace" | undefined,
43
+ paramSystemPrompt: string | undefined,
44
+ ) {
45
+ const identity = agentBody ?? paramSystemPrompt ?? null;
46
+ const identityInSystemPrompt = systemPromptMode && identity;
47
+ const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
48
+
49
+ let cliFlag: string | null = null;
50
+ if (identityInSystemPrompt && identity) {
51
+ cliFlag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
52
+ }
53
+
54
+ return { roleBlock, cliFlag, identityInSystemPrompt: !!identityInSystemPrompt };
55
+ }
56
+
57
+ // --- Fixtures ---
58
+
59
+ const AGENT_REPLACE = `---
60
+ model: anthropic/claude-sonnet-4-20250514
61
+ system-prompt: replace
62
+ auto-exit: true
63
+ ---
64
+
65
+ You are a specialized agent.`;
66
+
67
+ const AGENT_APPEND = `---
68
+ model: anthropic/claude-sonnet-4-20250514
69
+ system-prompt: append
70
+ ---
71
+
72
+ You are an appended identity.`;
73
+
74
+ const AGENT_DEFAULT = `---
75
+ model: anthropic/claude-sonnet-4-20250514
76
+ ---
77
+
78
+ You are a default agent.`;
79
+
80
+ const AGENT_INVALID = `---
81
+ model: anthropic/claude-sonnet-4-20250514
82
+ system-prompt: foobar
83
+ ---
84
+
85
+ Body here.`;
86
+
87
+ // --- Test 1: Frontmatter parsing ---
88
+ console.log("\n🧪 Frontmatter parsing of system-prompt field");
89
+
90
+ const r1 = parseFrontmatter(AGENT_REPLACE)!;
91
+ assert(r1.systemPromptMode === "replace", "system-prompt: replace → mode is 'replace'");
92
+ assert(r1.body === "You are a specialized agent.", "body extracted correctly");
93
+
94
+ const r2 = parseFrontmatter(AGENT_APPEND)!;
95
+ assert(r2.systemPromptMode === "append", "system-prompt: append → mode is 'append'");
96
+
97
+ const r3 = parseFrontmatter(AGENT_DEFAULT)!;
98
+ assert(r3.systemPromptMode === undefined, "no system-prompt field → mode is undefined");
99
+
100
+ const r4 = parseFrontmatter(AGENT_INVALID)!;
101
+ assert(r4.systemPromptMode === undefined, "system-prompt: foobar → mode is undefined (ignored)");
102
+
103
+ // --- Test 2: Identity routing ---
104
+ console.log("\n🧪 Identity routing (system prompt vs user message)");
105
+
106
+ const s1 = simulateRouting("You are X.", "replace", undefined);
107
+ assert(s1.roleBlock === "", "replace mode: roleBlock empty (not in task)");
108
+ assert(s1.cliFlag === "--system-prompt", "replace mode: uses --system-prompt flag");
109
+
110
+ const s2 = simulateRouting("You are X.", "append", undefined);
111
+ assert(s2.roleBlock === "", "append mode: roleBlock empty (not in task)");
112
+ assert(s2.cliFlag === "--append-system-prompt", "append mode: uses --append-system-prompt flag");
113
+
114
+ const s3 = simulateRouting("You are X.", undefined, undefined);
115
+ assert(s3.roleBlock === "\n\nYou are X.", "no mode: roleBlock contains identity");
116
+ assert(s3.cliFlag === null, "no mode: no CLI flag");
117
+
118
+ const s4 = simulateRouting(undefined, undefined, undefined);
119
+ assert(s4.roleBlock === "", "no identity: roleBlock empty");
120
+ assert(s4.cliFlag === null, "no identity: no CLI flag");
121
+
122
+ const s5 = simulateRouting(undefined, "replace", undefined);
123
+ assert(s5.roleBlock === "", "mode set but no body: roleBlock empty");
124
+ assert(s5.cliFlag === null, "mode set but no body: no CLI flag");
125
+
126
+ const s6 = simulateRouting(undefined, "replace", "Param identity");
127
+ assert(s6.cliFlag === "--system-prompt", "mode + param systemPrompt: uses CLI flag");
128
+ assert(s6.roleBlock === "", "mode + param systemPrompt: roleBlock empty");
129
+
130
+ // --- Test 3: End-to-end with temp agent files ---
131
+ console.log("\n🧪 End-to-end with temp agent files");
132
+
133
+ const tmpDir = mkdtempSync(join(tmpdir(), "pi-test-spm-"));
134
+ const agentsDir = join(tmpDir, ".pi", "agents");
135
+ mkdirSync(agentsDir, { recursive: true });
136
+
137
+ writeFileSync(join(agentsDir, "test-replace.md"), AGENT_REPLACE);
138
+ writeFileSync(join(agentsDir, "test-append.md"), AGENT_APPEND);
139
+ writeFileSync(join(agentsDir, "test-default.md"), AGENT_DEFAULT);
140
+
141
+ function loadFromDir(name: string) {
142
+ const p = join(agentsDir, `${name}.md`);
143
+ if (!existsSync(p)) return null;
144
+ return parseFrontmatter(readFileSync(p, "utf8"));
145
+ }
146
+
147
+ const t1 = loadFromDir("test-replace")!;
148
+ assert(t1.systemPromptMode === "replace", "file test-replace.md → replace mode");
149
+ assert(t1.body === "You are a specialized agent.", "file test-replace.md → body correct");
150
+
151
+ const t2 = loadFromDir("test-append")!;
152
+ assert(t2.systemPromptMode === "append", "file test-append.md → append mode");
153
+
154
+ const t3 = loadFromDir("test-default")!;
155
+ assert(t3.systemPromptMode === undefined, "file test-default.md → no mode");
156
+
157
+ rmSync(tmpDir, { recursive: true });
158
+
159
+ // --- Summary ---
160
+ console.log(`\n${"=".repeat(40)}`);
161
+ console.log(`Results: ${passed} passed, ${failed} failed`);
162
+ if (failed > 0) process.exit(1);
163
+ console.log("All tests passed! ✅\n");