Initial commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user