Adding support for herdr - agents in new tabs
This commit is contained in:
+51
-14
@@ -2,9 +2,9 @@
|
||||
* Integration test harness for pi-interactive-subagents.
|
||||
*
|
||||
* Provides utilities to:
|
||||
* - Detect whether tmux is available
|
||||
* - Detect the active terminal-surface backend
|
||||
* - Create isolated test environments with test agent definitions
|
||||
* - Start real pi sessions in tmux panes
|
||||
* - Start real pi sessions in Herdr tabs or tmux panes
|
||||
* - Poll for file creation and screen output
|
||||
* - Clean up panes and temp files after tests
|
||||
*/
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
isMuxAvailable,
|
||||
getMuxBackend,
|
||||
createSurface,
|
||||
createSurfaceSplit,
|
||||
sendCommand,
|
||||
@@ -31,10 +32,13 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
pollForExit,
|
||||
shellEscape,
|
||||
} from "../../pi-extension/subagents/tmux.ts";
|
||||
wrapWithCompletionFile,
|
||||
type MuxBackend,
|
||||
} from "../../pi-extension/subagents/surface.ts";
|
||||
|
||||
// Re-export tmux primitives for tests
|
||||
// Re-export backend-neutral surface primitives for tests
|
||||
export {
|
||||
createSurface,
|
||||
createSurfaceSplit,
|
||||
@@ -43,7 +47,9 @@ export {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
pollForExit,
|
||||
shellEscape,
|
||||
wrapWithCompletionFile,
|
||||
};
|
||||
|
||||
// ── Paths ──
|
||||
@@ -68,27 +74,49 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
|
||||
// ── 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";
|
||||
const activePiModel =
|
||||
process.env.PI_PROVIDER && process.env.PI_MODEL
|
||||
? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`
|
||||
: undefined;
|
||||
export const TEST_MODEL =
|
||||
process.env.PI_TEST_MODEL ?? activePiModel ?? "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 whether tmux is available in the current environment.
|
||||
* Returns ["tmux"] or [].
|
||||
*/
|
||||
export function getAvailableBackends(): string[] {
|
||||
return isMuxAvailable() ? ["tmux"] : [];
|
||||
/** Return the one backend selected automatically for this process. */
|
||||
export function getAvailableBackends(): MuxBackend[] {
|
||||
const backend = getMuxBackend();
|
||||
return backend ? [backend] : [];
|
||||
}
|
||||
|
||||
function parseHerdrJson(command: string, args: string[]): any {
|
||||
const output = execFileSync(command, args, { encoding: "utf8" });
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
export function focusSurface(surface: string): void {
|
||||
if (getMuxBackend() === "herdr") {
|
||||
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
||||
const tabId = pane.result?.pane?.tab_id;
|
||||
if (!tabId) throw new Error(`Herdr pane ${surface} has no tab id`);
|
||||
execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" });
|
||||
return;
|
||||
}
|
||||
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
export function getFocusedSurface(): string | null {
|
||||
try {
|
||||
if (getMuxBackend() === "herdr") {
|
||||
const workspace = process.env.HERDR_WORKSPACE_ID;
|
||||
if (!workspace) return null;
|
||||
const response = parseHerdrJson("herdr", ["pane", "list", "--workspace", workspace]);
|
||||
return response.result?.panes?.find((pane: any) => pane.focused)?.pane_id ?? null;
|
||||
}
|
||||
|
||||
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -110,7 +138,7 @@ export async function waitForFocusedSurface(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` +
|
||||
`Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` +
|
||||
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
@@ -139,7 +167,13 @@ export function createTestEnv(): TestEnv {
|
||||
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));
|
||||
const destination = join(agentsDir, file);
|
||||
cpSync(join(TEST_AGENTS_SRC, file), destination);
|
||||
const definition = readFileSync(destination, "utf8").replace(
|
||||
/^model:\s*.+$/m,
|
||||
`model: ${TEST_MODEL}`,
|
||||
);
|
||||
writeFileSync(destination, definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +261,9 @@ export function startPi(
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// Herdr does not retain terminal output for tabs that have never been shown.
|
||||
// Focus the test runner surface so waitForScreen can observe its transcript.
|
||||
focusSurface(surface);
|
||||
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
|
||||
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* Integration tests for the full subagent lifecycle.
|
||||
*
|
||||
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
|
||||
* Each test creates a tmux pane, runs pi with a task that uses the subagent
|
||||
* Each test creates a terminal 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 tmux:
|
||||
* Run inside Herdr, or inside tmux:
|
||||
* tmux new 'npm run test:integration'
|
||||
*
|
||||
* Configuration:
|
||||
@@ -37,8 +37,8 @@ import {
|
||||
const backends = getAvailableBackends();
|
||||
|
||||
if (backends.length === 0) {
|
||||
console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Integration tests for the tmux surface layer.
|
||||
* Integration tests for the selected terminal-surface backend.
|
||||
*
|
||||
* These tests exercise real tmux operations: creating panes,
|
||||
* These tests exercise real Herdr or tmux operations: creating surfaces,
|
||||
* sending commands, reading screen output, and closing panes.
|
||||
* No LLM calls — fast and free.
|
||||
*
|
||||
* Run inside tmux:
|
||||
* Run inside Herdr, or inside tmux:
|
||||
* tmux new 'npm run test:integration'
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
createTestEnv,
|
||||
cleanupTestEnv,
|
||||
createTrackedSurface,
|
||||
createTrackedSurfaceSplit,
|
||||
focusSurface,
|
||||
getFocusedSurface,
|
||||
waitForFocusedSurface,
|
||||
@@ -26,6 +25,8 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
pollForExit,
|
||||
wrapWithCompletionFile,
|
||||
sleep,
|
||||
uniqueId,
|
||||
trackTempFile,
|
||||
@@ -38,12 +39,12 @@ 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("⚠️ tmux is not available — skipping tmux-surface integration tests");
|
||||
console.log(" Run inside tmux to enable these tests.");
|
||||
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
describe(`surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
let env: TestEnv;
|
||||
|
||||
before(() => {
|
||||
@@ -55,7 +56,7 @@ for (const backend of backends) {
|
||||
});
|
||||
|
||||
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
|
||||
const anchor = createTrackedSurfaceSplit(env, "focus-anchor", "right");
|
||||
const anchor = createTrackedSurface(env, "focus-anchor");
|
||||
await sleep(1000);
|
||||
|
||||
focusSurface(anchor);
|
||||
@@ -71,19 +72,40 @@ for (const backend of backends) {
|
||||
|
||||
const markerA = uniqueId();
|
||||
const markerB = uniqueId();
|
||||
focusSurface(childA);
|
||||
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
|
||||
await waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50);
|
||||
focusSurface(childB);
|
||||
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
|
||||
await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50);
|
||||
focusSurface(anchor);
|
||||
await waitForFocusedSurface(anchor, 10_000);
|
||||
});
|
||||
|
||||
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(), anchor);
|
||||
it("polls file-based completion and closes an unfocused surface", async () => {
|
||||
const focusedBefore = getFocusedSurface();
|
||||
const surface = createTrackedSurface(env, "background-completion");
|
||||
await sleep(1000);
|
||||
|
||||
const completionFile = `${env.dir}/background.exit-code`;
|
||||
sendLongCommand(surface, wrapWithCompletionFile("false", completionFile));
|
||||
const result = await pollForExit(surface, new AbortController().signal, {
|
||||
interval: 100,
|
||||
completionFile,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { reason: "sentinel", exitCode: 1 });
|
||||
assert.equal(getFocusedSurface(), focusedBefore);
|
||||
|
||||
closeSurface(surface);
|
||||
untrackSurface(env, surface);
|
||||
assert.throws(() => readScreen(surface, 5));
|
||||
});
|
||||
|
||||
it("creates a surface, sends a command, reads output, and closes it", async () => {
|
||||
const surface = createTrackedSurface(env, "echo-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
sendCommand(surface, `echo "MARKER_${marker}"`);
|
||||
@@ -102,6 +124,7 @@ for (const backend of backends) {
|
||||
it("preserves shell special characters in echo output", async () => {
|
||||
const surface = createTrackedSurface(env, "escape-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
// Single-quoted string — $ and " are literal inside single quotes
|
||||
@@ -123,6 +146,7 @@ for (const backend of backends) {
|
||||
it("sends a long command via script file without truncation", async () => {
|
||||
const surface = createTrackedSurface(env, "long-cmd-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
const longValue = "X".repeat(500);
|
||||
@@ -145,6 +169,7 @@ for (const backend of backends) {
|
||||
it("reads screen asynchronously", async () => {
|
||||
const surface = createTrackedSurface(env, "async-read-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
sendCommand(surface, `echo "ASYNC_${marker}"`);
|
||||
@@ -164,7 +189,10 @@ for (const backend of backends) {
|
||||
|
||||
const m1 = uniqueId();
|
||||
const m2 = uniqueId();
|
||||
focusSurface(s1);
|
||||
sendCommand(s1, `echo "S1_${m1}"`);
|
||||
await sleep(750);
|
||||
focusSurface(s2);
|
||||
sendCommand(s2, `echo "S2_${m2}"`);
|
||||
await sleep(1500);
|
||||
|
||||
@@ -178,9 +206,10 @@ for (const backend of backends) {
|
||||
it("writes output to a file and verifies via surface", async () => {
|
||||
const surface = createTrackedSurface(env, "file-test");
|
||||
await sleep(1000);
|
||||
focusSurface(surface);
|
||||
|
||||
const marker = uniqueId();
|
||||
const filePath = `/tmp/pi-tmux-test-${marker}.txt`;
|
||||
const filePath = `/tmp/pi-surface-test-${marker}.txt`;
|
||||
|
||||
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||
|
||||
+74
-19
@@ -30,7 +30,12 @@ import {
|
||||
summarizeSessionStats,
|
||||
} from "../pi-extension/subagents/session.ts";
|
||||
|
||||
import { shellEscape } from "../pi-extension/subagents/tmux.ts";
|
||||
import {
|
||||
__pollForExitTest__,
|
||||
selectMuxBackend,
|
||||
shellEscape,
|
||||
} from "../pi-extension/subagents/surface.ts";
|
||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||
import {
|
||||
advanceStatusState,
|
||||
capStatusLines,
|
||||
@@ -56,7 +61,6 @@ import {
|
||||
runningChildrenCount,
|
||||
} from "../pi-extension/subagents/subagent-done.ts";
|
||||
import subagentDoneExtension from "../pi-extension/subagents/subagent-done.ts";
|
||||
import { __pollForExitTest__ } from "../pi-extension/subagents/tmux.ts";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -1172,10 +1176,12 @@ describe("subagent discovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bundled scout/researcher/worker all resolve as non-interactive (auto-exit)", () => {
|
||||
it("bundled scout/researcher/worker use the configured model and resolve as non-interactive", () => {
|
||||
for (const name of ["scout", "researcher", "worker"]) {
|
||||
const defs = testApi.loadAgentDefaults(name);
|
||||
assert.ok(defs, `expected bundled agent ${name} to be discoverable`);
|
||||
assert.equal(defs.model, "openai-codex/gpt-5.6-luna");
|
||||
assert.equal(defs.thinking, "xhigh");
|
||||
assert.equal(
|
||||
testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
|
||||
false,
|
||||
@@ -1209,10 +1215,16 @@ describe("subagent discovery", () => {
|
||||
it("getToolExtensionPath maps custom tools and skips built-ins", () => {
|
||||
assert.equal(testApi.getToolExtensionPath("read"), undefined);
|
||||
assert.equal(testApi.getToolExtensionPath("bash"), undefined);
|
||||
assert.ok(testApi.getToolExtensionPath("web_search")?.endsWith("web-search/index.ts"));
|
||||
assert.ok(testApi.getToolExtensionPath("safe_bash")?.endsWith("tools/safe-bash.ts"));
|
||||
// Spawning tools are registered by this extension itself.
|
||||
assert.ok(testApi.getToolExtensionPath("subagent")?.endsWith("index.ts"));
|
||||
|
||||
withTempDir((dir) => {
|
||||
const extensionPath = join(dir, "portable-test-tool.ts");
|
||||
writeFileSync(extensionPath, "export default function () {}\n");
|
||||
subagentsModule.registerToolExtension("portable_test_tool", extensionPath);
|
||||
assert.equal(testApi.getToolExtensionPath("portable_test_tool"), extensionPath);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores invalid session-mode values", async () => {
|
||||
@@ -1741,8 +1753,21 @@ describe("subagent-done.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tmux.ts interpretExitSidecar", () => {
|
||||
const { interpretExitSidecar } = __pollForExitTest__;
|
||||
describe("surface exit signals", () => {
|
||||
const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__;
|
||||
|
||||
it("reads and removes a shell completion file", () => {
|
||||
withTempDir((dir) => {
|
||||
const completionFile = join(dir, "child.exit-code");
|
||||
writeFileSync(completionFile, "7\n");
|
||||
assert.deepEqual(readCompletionFile(completionFile), {
|
||||
reason: "sentinel",
|
||||
exitCode: 7,
|
||||
});
|
||||
assert.equal(existsSync(completionFile), false);
|
||||
assert.equal(readCompletionFile(completionFile), null);
|
||||
});
|
||||
});
|
||||
|
||||
it("no longer decodes ping payloads (ask_question keeps the session open instead)", () => {
|
||||
// ask_question writes a `.ask` signal, not a `.exit` ping sidecar, so an
|
||||
@@ -2460,19 +2485,11 @@ describe("subagent status renderer", () => {
|
||||
});
|
||||
|
||||
describe("subagent startup delay", () => {
|
||||
it("defaults to 500ms when no env var is set", () => {
|
||||
it("uses backend-specific defaults", () => {
|
||||
const testApi = (subagentsModule as any).__test__;
|
||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||
assert.equal(typeof testApi.getShellReadyDelayMs, "function");
|
||||
|
||||
const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
||||
delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
||||
try {
|
||||
assert.equal(testApi.getShellReadyDelayMs(), 500);
|
||||
} finally {
|
||||
if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
||||
else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original;
|
||||
}
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||
});
|
||||
|
||||
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
|
||||
@@ -2652,7 +2669,45 @@ describe("subagent display helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tmux.ts", () => {
|
||||
describe("surface backend selection", () => {
|
||||
it("prefers Herdr and otherwise falls back to tmux", () => {
|
||||
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr");
|
||||
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux");
|
||||
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null);
|
||||
});
|
||||
|
||||
it("parses Herdr tab and pane creation responses", () => {
|
||||
assert.deepEqual(
|
||||
parseCreatedTab(JSON.stringify({
|
||||
result: {
|
||||
root_pane: { pane_id: "w3:p9" },
|
||||
tab: { tab_id: "w3:t6" },
|
||||
},
|
||||
})),
|
||||
{ paneId: "w3:p9", tabId: "w3:t6" },
|
||||
);
|
||||
assert.equal(
|
||||
parseCreatedPane(JSON.stringify({ result: { pane: { pane_id: "w3:p10" } } })),
|
||||
"w3:p10",
|
||||
);
|
||||
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent completion wrapper", () => {
|
||||
it("records the child exit code without losing the terminal sentinel", () => {
|
||||
const wrapped = (subagentsModule as any).__test__.wrapWithCompletionFile(
|
||||
"pi --session test.jsonl",
|
||||
"/tmp/subagent exit",
|
||||
);
|
||||
assert.match(wrapped, /^pi --session test\.jsonl; exit_code=\$\?/);
|
||||
assert.match(wrapped, /printf '%s\\n' "\$exit_code" > '\/tmp\/subagent exit'/);
|
||||
assert.match(wrapped, /__SUBAGENT_DONE_\$\{exit_code\}__/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface.ts", () => {
|
||||
describe("shellEscape", () => {
|
||||
it("wraps in single quotes", () => {
|
||||
assert.equal(shellEscape("hello"), "'hello'");
|
||||
|
||||
Reference in New Issue
Block a user