Initial commit

This commit is contained in:
Amos
2026-06-26 22:25:19 +03:00
commit c6c00f89a7
29 changed files with 14358 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
---
name: test-echo
description: Integration test agent — completes simple file-writing tasks
model: anthropic/claude-haiku-4-5
tools: read, bash, write, edit
spawning: false
auto-exit: true
disable-model-invocation: true
---
You are a test agent. Complete the task given to you immediately. Be direct and concise.
When asked to write content to a file, do it right away using the bash tool.
Do not ask questions. Do not explain. Just execute the task.
+11
View File
@@ -0,0 +1,11 @@
---
name: test-ping
description: Integration test agent — calls caller_ping instead of completing task
model: anthropic/claude-haiku-4-5
tools: read, bash
spawning: false
disable-model-invocation: true
---
You are a test agent. When given ANY task, you must call the caller_ping tool with the message set to "PING: " followed by the task text you received.
Do NOT complete the task yourself. Do NOT use any other tools. ONLY call caller_ping.
+381
View File
@@ -0,0 +1,381 @@
/**
* Integration test harness for pi-interactive-subagents.
*
* Provides utilities to:
* - Detect available mux backends (cmux, tmux, zellij)
* - Create isolated test environments with test agent definitions
* - Start real pi sessions in mux surfaces
* - Poll for file creation and screen output
* - Clean up surfaces and temp files after tests
*/
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
cpSync,
readdirSync,
rmSync,
existsSync,
readFileSync,
unlinkSync,
} from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import {
getMuxBackend,
createSurface,
createSurfaceSplit,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
sendEscape,
shellEscape,
parseCmuxFocusedSnapshotFromJson,
parseCmuxPaneRefForSurfaceFromJson,
type MuxBackend,
} from "../../pi-extension/subagents/cmux.ts";
// Re-export mux primitives for tests
export {
createSurface,
createSurfaceSplit,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
sendEscape,
shellEscape,
};
export type { MuxBackend };
// ── Paths ──
const HARNESS_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(HARNESS_DIR, "../..");
const TEST_AGENTS_SRC = join(HARNESS_DIR, "agents");
/**
* Absolute path to the extension source in the working tree.
*
* Integration tests must exercise the code on the current branch — NOT the
* version installed as a pi-package under `~/.pi/agent/git/...` or the project
* mirror under `.pi/git/...`, which stays pinned to the last released tag.
*
* We force-load this file via `pi -ne -e <path>` in startPi() below so local
* edits are always the code under test, regardless of what pi-packages are
* installed on the host.
*/
const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.ts");
// ── Configuration ──
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "anthropic/claude-haiku-4-5";
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
// ── Backend detection ──
/**
* Detect which mux backends are actually available in the current environment.
* Temporarily sets PI_SUBAGENT_MUX to probe each backend.
*/
export function getAvailableBackends(): MuxBackend[] {
const backends: MuxBackend[] = [];
const orig = process.env.PI_SUBAGENT_MUX;
for (const backend of ["cmux", "tmux", "zellij"] as MuxBackend[]) {
process.env.PI_SUBAGENT_MUX = backend;
try {
if (getMuxBackend() === backend) backends.push(backend);
} catch {}
}
if (orig === undefined) delete process.env.PI_SUBAGENT_MUX;
else process.env.PI_SUBAGENT_MUX = orig;
return backends;
}
export function setBackend(backend: MuxBackend): string | undefined {
const prev = process.env.PI_SUBAGENT_MUX;
process.env.PI_SUBAGENT_MUX = backend;
return prev;
}
export function restoreBackend(prev: string | undefined): void {
if (prev === undefined) delete process.env.PI_SUBAGENT_MUX;
else process.env.PI_SUBAGENT_MUX = prev;
}
export function focusSurface(backend: MuxBackend, surface: string): void {
if (backend === "cmux") {
const pane = getSurfacePane(backend, surface);
if (pane) execFileSync("cmux", ["focus-pane", "--pane", pane], { encoding: "utf8" });
execFileSync("cmux", ["focus-panel", "--panel", surface], { encoding: "utf8" });
return;
}
if (backend === "tmux") {
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
return;
}
throw new Error(`Focus helpers are not implemented for ${backend}`);
}
export function getFocusedSurface(backend: MuxBackend): string | null {
if (backend === "cmux") {
const info = execFileSync("cmux", ["identify", "--json"], { encoding: "utf8" });
return parseCmuxFocusedSnapshotFromJson(info)?.surfaceRef ?? null;
}
if (backend === "tmux") {
try {
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
encoding: "utf8",
});
const activeLine = panes.split("\n").find((line) => line.endsWith(" 1"));
return activeLine?.split(" ")[0] ?? null;
} catch {
return null;
}
}
throw new Error(`Focus helpers are not implemented for ${backend}`);
}
export function getSurfacePane(backend: MuxBackend, surface: string): string | null {
if (backend === "cmux") {
const info = execFileSync("cmux", ["identify", "--surface", surface], { encoding: "utf8" });
return parseCmuxPaneRefForSurfaceFromJson(info, surface);
}
if (backend === "tmux") return surface;
throw new Error(`Pane lookup is not implemented for ${backend}`);
}
export async function waitForFocusedSurface(
backend: MuxBackend,
surface: string,
timeout: number = PI_TIMEOUT,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (getFocusedSurface(backend) === surface) return;
await sleep(200);
}
throw new Error(
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
);
}
// ── Test environment ──
export interface TestEnv {
/** Temp directory serving as the test project root */
dir: string;
/** Active mux backend for this test run */
backend: MuxBackend;
/** Surfaces created during the test (cleaned up automatically) */
surfaces: string[];
/** Temp files to clean up */
tempFiles: string[];
}
/**
* Create an isolated test environment with test agent definitions.
* The temp dir has `.pi/agents/` containing copies of all test agents.
*/
export function createTestEnv(backend: MuxBackend): TestEnv {
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
const agentsDir = join(dir, ".pi", "agents");
mkdirSync(agentsDir, { recursive: true });
// Copy test agent definitions into the project-local agents dir
if (existsSync(TEST_AGENTS_SRC)) {
for (const file of readdirSync(TEST_AGENTS_SRC)) {
if (file.endsWith(".md")) {
cpSync(join(TEST_AGENTS_SRC, file), join(agentsDir, file));
}
}
}
return { dir, backend, surfaces: [], tempFiles: [] };
}
/**
* Clean up all resources created during the test.
*/
export function cleanupTestEnv(env: TestEnv): void {
for (const surface of env.surfaces) {
try {
closeSurface(surface);
} catch {}
}
for (const file of env.tempFiles) {
try {
unlinkSync(file);
} catch {}
}
try {
rmSync(env.dir, { recursive: true, force: true });
} catch {}
}
/**
* Create a surface and register it for automatic cleanup.
*/
export function createTrackedSurface(env: TestEnv, name: string): string {
const surface = createSurface(name);
env.surfaces.push(surface);
return surface;
}
export function createTrackedSurfaceSplit(
env: TestEnv,
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
const surface = createSurfaceSplit(name, direction, fromSurface);
env.surfaces.push(surface);
return surface;
}
/**
* Remove a surface from tracking (after manual close).
*/
export function untrackSurface(env: TestEnv, surface: string): void {
env.surfaces = env.surfaces.filter((s) => s !== surface);
}
// ── Pi session management ──
/**
* Start a pi session in a mux surface with the subagents extension loaded.
* Returns immediately — the pi process runs asynchronously in the surface.
*
* The command ends with a sentinel so we can detect when pi exits:
* `pi ...; echo '__TEST_DONE_'$?'__'`
*/
export function startPi(
surface: string,
testDir: string,
task: string,
opts?: { model?: string; extraArgs?: string },
): void {
const model = opts?.model ?? TEST_MODEL;
const extra = opts?.extraArgs ?? "";
// Force pi to load the working-tree extension (not an installed pi-package
// snapshot). `-ne` disables extension auto-discovery, `-e <path>` loads the
// current branch's source directly. Without this, the tests silently run
// against whatever version is checked out under `~/.pi/agent/git/...`.
const cmd = [
`cd ${shellEscape(testDir)} &&`,
`pi`,
`-ne`,
`-e ${shellEscape(EXTENSION_SOURCE)}`,
`--model ${shellEscape(model)}`,
extra,
shellEscape(task),
]
.filter(Boolean)
.join(" ");
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
});
}
// ── Polling helpers ──
/**
* Poll until a regex pattern appears in the surface's screen output.
* Throws on timeout with the last screen contents for debugging.
*/
export async function waitForScreen(
surface: string,
pattern: RegExp,
timeout: number = PI_TIMEOUT,
lines: number = 200,
): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const screen = await readScreenAsync(surface, lines);
if (pattern.test(screen)) return screen;
} catch {}
await sleep(2000);
}
let finalScreen = "";
try {
finalScreen = readScreen(surface, lines);
} catch {}
throw new Error(
`Timeout (${timeout}ms) waiting for pattern ${pattern}.\nLast screen:\n${finalScreen.slice(-1000)}`,
);
}
/**
* Poll until a file exists and optionally matches a content pattern.
* Returns the file content on success.
*/
export async function waitForFile(
path: string,
timeout: number = PI_TIMEOUT,
contentPattern?: RegExp,
): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (existsSync(path)) {
const content = readFileSync(path, "utf8");
if (!contentPattern || contentPattern.test(content)) return content;
}
await sleep(2000);
}
throw new Error(
`Timeout (${timeout}ms) waiting for file: ${path}` +
(contentPattern ? ` matching ${contentPattern}` : ""),
);
}
/**
* Wait for the pi process in a surface to exit (sentinel detection).
* Returns the exit code.
*/
export async function waitForPiExit(
surface: string,
timeout: number = PI_TIMEOUT,
): Promise<number> {
const screen = await waitForScreen(surface, /__TEST_DONE_(\d+)__/, timeout);
const match = screen.match(/__TEST_DONE_(\d+)__/);
return match ? parseInt(match[1], 10) : -1;
}
// ── Utilities ──
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function uniqueId(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
/**
* Register a temp file for cleanup.
*/
export function trackTempFile(env: TestEnv, path: string): void {
env.tempFiles.push(path);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Integration tests for the multiplexer surface layer.
*
* These tests exercise real mux operations: creating panes,
* sending commands, reading screen output, and closing surfaces.
* No LLM calls — fast and free.
*
* Run inside a supported multiplexer:
* cmux bash -c 'npm run test:integration'
* tmux new 'npm run test:integration'
* zellij --session pi # then run: npm run test:integration
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { unlinkSync } from "node:fs";
import {
getAvailableBackends,
setBackend,
restoreBackend,
createTestEnv,
cleanupTestEnv,
createTrackedSurface,
createTrackedSurfaceSplit,
focusSurface,
getFocusedSurface,
getSurfacePane,
waitForFocusedSurface,
untrackSurface,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
sendEscape,
sleep,
uniqueId,
trackTempFile,
waitForFile,
waitForScreen,
type TestEnv,
} from "./harness.ts";
const backends = getAvailableBackends();
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
if (backends.length === 0) {
console.log("⚠️ No mux backend available — skipping mux-surface integration tests");
console.log(" Run inside cmux or tmux to enable these tests.");
}
for (const backend of backends) {
describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => {
let prevMux: string | undefined;
let env: TestEnv;
before(() => {
prevMux = setBackend(backend);
env = createTestEnv(backend);
});
after(() => {
cleanupTestEnv(env);
restoreBackend(prevMux);
});
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
await sleep(1000);
focusSurface(backend, anchor);
await waitForFocusedSurface(backend, anchor, 10_000);
const childA = createTrackedSurface(env, "focus-child-a");
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
assert.equal(getFocusedSurface(backend), anchor);
const childB = createTrackedSurface(env, "focus-child-b");
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
assert.equal(getFocusedSurface(backend), anchor);
if (backend === "cmux") {
const paneA = getSurfacePane(backend, childA);
const paneB = getSurfacePane(backend, childB);
assert.ok(paneA, `Expected pane ref for ${childA}`);
assert.ok(paneB, `Expected pane ref for ${childB}`);
assert.equal(paneB, paneA);
}
const markerA = uniqueId();
const markerB = uniqueId();
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
await Promise.all([
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50),
]);
assert.equal(getFocusedSurface(backend), anchor);
});
it("creates a surface, sends a command, reads output, and closes it", async () => {
const surface = createTrackedSurface(env, "echo-test");
await sleep(1000);
const marker = uniqueId();
sendCommand(surface, `echo "MARKER_${marker}"`);
await sleep(1500);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`MARKER_${marker}`),
`Expected screen to contain MARKER_${marker}. Got:\n${screen}`,
);
closeSurface(surface);
untrackSurface(env, surface);
});
it("preserves shell special characters in echo output", async () => {
const surface = createTrackedSurface(env, "escape-test");
await sleep(1000);
const marker = uniqueId();
// Single-quoted string — $ and " are literal inside single quotes
sendCommand(surface, `echo 'SPEC_${marker}_$HOME_"quotes"_done'`);
await sleep(1500);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`SPEC_${marker}`),
`Expected special-char output. Got:\n${screen}`,
);
// $ should be literal inside single quotes
assert.ok(
screen.includes("$HOME"),
`Expected literal $HOME in output. Got:\n${screen}`,
);
});
it("sends a long command via script file without truncation", async () => {
const surface = createTrackedSurface(env, "long-cmd-test");
await sleep(1000);
const marker = uniqueId();
const longValue = "X".repeat(500);
const command = `echo "LONG_${marker}_${longValue}_END"`;
sendLongCommand(surface, command);
await sleep(2000);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`LONG_${marker}`),
`Expected long command output. Got:\n${screen.slice(0, 300)}...`,
);
assert.ok(
screen.includes("_END"),
`Expected full output (not truncated). Got:\n${screen.slice(-300)}`,
);
});
it("reads screen asynchronously", async () => {
const surface = createTrackedSurface(env, "async-read-test");
await sleep(1000);
const marker = uniqueId();
sendCommand(surface, `echo "ASYNC_${marker}"`);
await sleep(1500);
const screen = await readScreenAsync(surface, 50);
assert.ok(
screen.includes(`ASYNC_${marker}`),
`Async read should find marker. Got:\n${screen}`,
);
});
it("manages multiple surfaces concurrently", async () => {
const s1 = createTrackedSurface(env, "multi-1");
const s2 = createTrackedSurface(env, "multi-2");
await sleep(1500);
const m1 = uniqueId();
const m2 = uniqueId();
sendCommand(s1, `echo "S1_${m1}"`);
sendCommand(s2, `echo "S2_${m2}"`);
await sleep(1500);
const screen1 = readScreen(s1, 50);
const screen2 = readScreen(s2, 50);
assert.ok(screen1.includes(`S1_${m1}`), `Surface 1 missing marker. Got:\n${screen1}`);
assert.ok(screen2.includes(`S2_${m2}`), `Surface 2 missing marker. Got:\n${screen2}`);
});
it("writes output to a file and verifies via surface", async () => {
const surface = createTrackedSurface(env, "file-test");
await sleep(1000);
const marker = uniqueId();
const filePath = `/tmp/pi-mux-test-${marker}.txt`;
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
await waitForScreen(surface, new RegExp(`WRITTEN_${marker}`), 10_000, 50);
const content = await waitForFile(filePath, 10_000, new RegExp(`FILE_${marker}`));
assert.ok(content.includes(`FILE_${marker}`), `File content wrong. Got: ${content}`);
// Clean up
try {
unlinkSync(filePath);
} catch {}
});
it("delivers Escape as byte 27 to the target surface", async () => {
const surface = createTrackedSurface(env, "escape-byte-test");
await sleep(1000);
const marker = uniqueId();
const byteFile = `/tmp/pi-mux-escape-${marker}.txt`;
trackTempFile(env, byteFile);
const nodeProgram =
"const fs = require('node:fs');" +
"if (!process.stdin.isTTY) throw new Error('stdin is not a TTY');" +
"process.stdin.setRawMode(true);" +
"process.stdin.resume();" +
"process.stdout.write('ESC_READY\\n');" +
"process.stdin.once('data', (chunk) => {" +
`fs.writeFileSync(${JSON.stringify(byteFile)}, Array.from(chunk).join(','));` +
"process.exit(0);" +
"});";
const command = `node -e ${JSON.stringify(nodeProgram)}`;
sendLongCommand(surface, command);
await waitForScreen(surface, /ESC_READY/, 15_000, 50);
sendEscape(surface);
const content = await waitForFile(byteFile, 15_000, /^27$/);
assert.equal(content.trim(), "27");
});
});
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Integration tests for the full subagent lifecycle.
*
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
* Each test creates a mux surface, runs pi with a task that uses the subagent
* tool, and verifies the outcome via marker files and screen output.
*
* Costs: ~$0.01-0.05 per test run (haiku).
* Duration: ~30-90s per test.
*
* Run inside a supported multiplexer:
* cmux bash -c 'npm run test:integration'
* tmux new 'npm run test:integration'
*
* Configuration:
* PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5)
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import {
getAvailableBackends,
setBackend,
restoreBackend,
createTestEnv,
cleanupTestEnv,
createTrackedSurface,
startPi,
waitForScreen,
waitForFile,
sleep,
uniqueId,
trackTempFile,
readScreen,
PI_TIMEOUT,
type TestEnv,
} from "./harness.ts";
const backends = getAvailableBackends();
if (backends.length === 0) {
console.log("⚠️ No mux backend available — skipping subagent lifecycle integration tests");
console.log(" Run inside cmux or tmux to enable these tests.");
}
for (const backend of backends) {
describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
let prevMux: string | undefined;
let env: TestEnv;
before(() => {
prevMux = setBackend(backend);
env = createTestEnv(backend);
});
after(() => {
cleanupTestEnv(env);
restoreBackend(prevMux);
});
// ── Basic spawn + completion ──
it("spawns a subagent that writes a file and verifies the session", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-echo-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `echo-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Echo-${id}"`,
` agent: "test-echo"`,
` task: "Run this bash command: echo 'PASS_${id}' > '${markerFile}'"`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the subagent result, say INTEGRATION_COMPLETE.`,
].join("\n");
startPi(surface, env.dir, task);
// Verify: subagent created the marker file
const content = await waitForFile(markerFile, PI_TIMEOUT, /PASS/);
assert.ok(
content.includes(`PASS_${id}`),
`Marker file should contain PASS_${id}. Got: ${content.trim()}`,
);
// Verify: outer pi received the subagent result
const screen = await waitForScreen(
surface,
/INTEGRATION_COMPLETE|completed|Sub-agent.*"Echo/i,
PI_TIMEOUT,
);
// Verify: session file was created (shown in steer result)
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
if (sessionMatch) {
const sessionFile = sessionMatch[1];
assert.ok(existsSync(sessionFile), `Subagent session file should exist: ${sessionFile}`);
const lines = readFileSync(sessionFile, "utf8").trim().split("\n");
assert.ok(lines.length >= 2, `Session should have ≥2 entries, got ${lines.length}`);
const header = JSON.parse(lines[0]);
assert.equal(header.type, "session", "First entry should be session header");
assert.ok(header.id, "Session header should have an id");
}
});
// ── In-progress activity snapshots ──
it("keeps a long active tool call from surfacing false stalled status", async () => {
const id = uniqueId();
const startFile = `/tmp/pi-integ-status-start-${id}.txt`;
const markerFile = `/tmp/pi-integ-status-${id}.txt`;
trackTempFile(env, startFile);
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `status-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Status-${id}"`,
` agent: "test-echo"`,
` task: "Run this bash command: echo 'START_${id}' > '${startFile}'; sleep 90; echo 'STATUS_${id}' > '${markerFile}'"`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the subagent result, say STATUS_TEST_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
const activeScreen = await waitForScreen(surface, /active[\s\S]*bash|bash[\s\S]*active/i, PI_TIMEOUT, 300);
assert.doesNotMatch(activeScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
await waitForFile(startFile, PI_TIMEOUT, /START_/);
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the long sleep");
await sleep(65_000);
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the watchdog assertion");
const watchdogScreen = readScreen(surface, 300);
assert.doesNotMatch(watchdogScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
const content = await waitForFile(markerFile, PI_TIMEOUT, /STATUS_/);
assert.ok(content.includes(`STATUS_${id}`), `Marker file should contain STATUS_${id}`);
const completionScreen = await waitForScreen(
surface,
/STATUS_TEST_DONE|completed|Sub-agent.*"Status-/i,
PI_TIMEOUT,
300,
);
assert.ok(/STATUS_TEST_DONE|completed/i.test(completionScreen));
});
// ── Parallel subagent spawn ──
it("spawns two subagents in parallel and both complete", async () => {
const id = uniqueId();
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
trackTempFile(env, fileA);
trackTempFile(env, fileB);
const surface = createTrackedSurface(env, `parallel-${id}`);
await sleep(1000);
const task = [
`You must call the subagent tool TWICE. Make both calls before waiting for results.`,
``,
`First call:`,
` name: "ParaA-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
``,
`Second call:`,
` name: "ParaB-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
``,
`Call both subagent tools NOW, do not wait between them.`,
].join("\n");
startPi(surface, env.dir, task);
// Both marker files should appear
const [contentA, contentB] = await Promise.all([
waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
]);
assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
});
// ── Fork mode ──
it("fork mode creates a child session linked to the parent", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-fork-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `fork-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Fork-${id}"`,
` fork: true`,
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
`Do not set the agent parameter. Just set name, fork, and task.`,
`After you receive the result, say FORK_COMPLETE.`,
].join("\n");
startPi(surface, env.dir, task);
// Verify: forked subagent created the file
const content = await waitForFile(markerFile, PI_TIMEOUT, /FORK_OK/);
assert.ok(content.includes(`FORK_OK_${id}`), `Fork marker file should exist with content`);
// Wait for the outer pi to show the result
const screen = await waitForScreen(
surface,
/FORK_COMPLETE|completed|Sub-agent.*"Fork/i,
PI_TIMEOUT,
);
// Verify: the forked session has a parent link
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
if (sessionMatch) {
const sessionFile = sessionMatch[1];
assert.ok(existsSync(sessionFile), `Fork session file should exist: ${sessionFile}`);
const entries = readFileSync(sessionFile, "utf8")
.trim()
.split("\n")
.map((l) => JSON.parse(l));
const header = entries[0];
assert.equal(header.type, "session", "First entry should be session header");
assert.ok(header.parentSession, "Fork session should have parentSession field");
// Fork sessions include parent context (model_change entries etc.)
assert.ok(entries.length >= 2, "Fork session should have context entries beyond header");
}
});
// ── caller_ping ──
it("subagent caller_ping sends notification back to the parent", async () => {
const id = uniqueId();
const surface = createTrackedSurface(env, `ping-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Ping-${id}"`,
` agent: "test-ping"`,
` task: "PING_TEST_${id}"`,
`Just call the subagent tool once. Do not do anything else before calling it.`,
].join("\n");
startPi(surface, env.dir, task);
// The test-ping agent calls caller_ping, which steers a "needs help" message
// back to the outer pi. Look for it on screen.
const screen = await waitForScreen(
surface,
/needs help|PING|caller_ping|ping/i,
PI_TIMEOUT,
);
assert.ok(
/needs help|PING/i.test(screen),
`Screen should show ping notification. Got:\n${screen.slice(-800)}`,
);
});
// ── Agent discovery ──
it("subagent discovers project-local test agents", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-discovery-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `discovery-${id}`);
await sleep(1000);
// Use subagents_list to verify test agents are discoverable,
// then spawn one to prove it works end-to-end.
const task = [
`First, call the subagents_list tool to see available agents.`,
`Then call the subagent tool:`,
` name: "Disco-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DISCO_${id}' > '${markerFile}'"`,
`After you receive the subagent result, say DISCOVERY_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
// The test-echo agent (discovered from project .pi/agents/) should work
const content = await waitForFile(markerFile, PI_TIMEOUT, /DISCO/);
assert.ok(content.includes(`DISCO_${id}`), `Discovery test marker should exist`);
});
// ── Subagent with custom system prompt ──
it("passes systemPrompt to subagent", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-sysprompt-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `sysprompt-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these parameters:`,
` name: "SysP-${id}"`,
` agent: "test-echo"`,
` systemPrompt: "Always start your response with CUSTOM_PROMPT_ACTIVE."`,
` task: "Write 'SYSPROMPT_${id}' to ${markerFile} using bash: echo 'SYSPROMPT_${id}' > '${markerFile}'"`,
`After the subagent completes, say SYSPROMPT_TEST_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
const content = await waitForFile(markerFile, PI_TIMEOUT, /SYSPROMPT/);
assert.ok(content.includes(`SYSPROMPT_${id}`), `System prompt test marker should exist`);
});
});
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Smoke tests for the systemPromptMode feature.
* Tests: frontmatter parsing, identity routing, CLI flag generation.
*/
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(` ✅ ${msg}`);
passed++;
} else {
console.log(` ❌ ${msg}`);
failed++;
}
}
// --- Extracted logic under test ---
function parseFrontmatter(content: string) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const frontmatter = match[1];
const get = (key: string) => {
const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
return m ? m[1].trim() : undefined;
};
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
const spm = get("system-prompt");
return {
systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
body: body || undefined,
};
}
function simulateRouting(
agentBody: string | undefined,
systemPromptMode: "append" | "replace" | undefined,
paramSystemPrompt: string | undefined,
) {
const identity = agentBody ?? paramSystemPrompt ?? null;
const identityInSystemPrompt = systemPromptMode && identity;
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
let cliFlag: string | null = null;
if (identityInSystemPrompt && identity) {
cliFlag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
}
return { roleBlock, cliFlag, identityInSystemPrompt: !!identityInSystemPrompt };
}
// --- Fixtures ---
const AGENT_REPLACE = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: replace
auto-exit: true
---
You are a specialized agent.`;
const AGENT_APPEND = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: append
---
You are an appended identity.`;
const AGENT_DEFAULT = `---
model: anthropic/claude-sonnet-4-20250514
---
You are a default agent.`;
const AGENT_INVALID = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: foobar
---
Body here.`;
// --- Test 1: Frontmatter parsing ---
console.log("\n🧪 Frontmatter parsing of system-prompt field");
const r1 = parseFrontmatter(AGENT_REPLACE)!;
assert(r1.systemPromptMode === "replace", "system-prompt: replace → mode is 'replace'");
assert(r1.body === "You are a specialized agent.", "body extracted correctly");
const r2 = parseFrontmatter(AGENT_APPEND)!;
assert(r2.systemPromptMode === "append", "system-prompt: append → mode is 'append'");
const r3 = parseFrontmatter(AGENT_DEFAULT)!;
assert(r3.systemPromptMode === undefined, "no system-prompt field → mode is undefined");
const r4 = parseFrontmatter(AGENT_INVALID)!;
assert(r4.systemPromptMode === undefined, "system-prompt: foobar → mode is undefined (ignored)");
// --- Test 2: Identity routing ---
console.log("\n🧪 Identity routing (system prompt vs user message)");
const s1 = simulateRouting("You are X.", "replace", undefined);
assert(s1.roleBlock === "", "replace mode: roleBlock empty (not in task)");
assert(s1.cliFlag === "--system-prompt", "replace mode: uses --system-prompt flag");
const s2 = simulateRouting("You are X.", "append", undefined);
assert(s2.roleBlock === "", "append mode: roleBlock empty (not in task)");
assert(s2.cliFlag === "--append-system-prompt", "append mode: uses --append-system-prompt flag");
const s3 = simulateRouting("You are X.", undefined, undefined);
assert(s3.roleBlock === "\n\nYou are X.", "no mode: roleBlock contains identity");
assert(s3.cliFlag === null, "no mode: no CLI flag");
const s4 = simulateRouting(undefined, undefined, undefined);
assert(s4.roleBlock === "", "no identity: roleBlock empty");
assert(s4.cliFlag === null, "no identity: no CLI flag");
const s5 = simulateRouting(undefined, "replace", undefined);
assert(s5.roleBlock === "", "mode set but no body: roleBlock empty");
assert(s5.cliFlag === null, "mode set but no body: no CLI flag");
const s6 = simulateRouting(undefined, "replace", "Param identity");
assert(s6.cliFlag === "--system-prompt", "mode + param systemPrompt: uses CLI flag");
assert(s6.roleBlock === "", "mode + param systemPrompt: roleBlock empty");
// --- Test 3: End-to-end with temp agent files ---
console.log("\n🧪 End-to-end with temp agent files");
const tmpDir = mkdtempSync(join(tmpdir(), "pi-test-spm-"));
const agentsDir = join(tmpDir, ".pi", "agents");
mkdirSync(agentsDir, { recursive: true });
writeFileSync(join(agentsDir, "test-replace.md"), AGENT_REPLACE);
writeFileSync(join(agentsDir, "test-append.md"), AGENT_APPEND);
writeFileSync(join(agentsDir, "test-default.md"), AGENT_DEFAULT);
function loadFromDir(name: string) {
const p = join(agentsDir, `${name}.md`);
if (!existsSync(p)) return null;
return parseFrontmatter(readFileSync(p, "utf8"));
}
const t1 = loadFromDir("test-replace")!;
assert(t1.systemPromptMode === "replace", "file test-replace.md → replace mode");
assert(t1.body === "You are a specialized agent.", "file test-replace.md → body correct");
const t2 = loadFromDir("test-append")!;
assert(t2.systemPromptMode === "append", "file test-append.md → append mode");
const t3 = loadFromDir("test-default")!;
assert(t3.systemPromptMode === undefined, "file test-default.md → no mode");
rmSync(tmpDir, { recursive: true });
// --- Summary ---
console.log(`\n${"=".repeat(40)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
console.log("All tests passed! ✅\n");
+2378
View File
File diff suppressed because it is too large Load Diff