Adding support for herdr - agents in new tabs

This commit is contained in:
jb
2026-08-26 10:57:45 -04:00
parent c3e8b53c07
commit b3884f6c35
14 changed files with 660 additions and 331 deletions
+51 -14
View File
@@ -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`),
});
+4 -4
View File
@@ -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}"`);