made extension tmux-only, replaced cmux.ts with tmux.ts (~1368 -> 354 lines)
Removed the cmux, zellij, and wezterm backends, the MuxBackend type, the PI_SUBAGENT_MUX override, and dead exports (renameCurrentTab, renameWorkspace, sendEscape, isFishShell, exitStatusVar). The ten-function surface API used by index.ts is unchanged. Integration harness and tests are tmux-only; README and package.json now state tmux-only with a fork note pointing multi-multiplexer users at the upstream project.
This commit is contained in:
+27
-92
@@ -2,11 +2,11 @@
|
||||
* Integration test harness for pi-interactive-subagents.
|
||||
*
|
||||
* Provides utilities to:
|
||||
* - Detect available mux backends (cmux, tmux, zellij)
|
||||
* - Detect whether tmux is available
|
||||
* - Create isolated test environments with test agent definitions
|
||||
* - Start real pi sessions in mux surfaces
|
||||
* - Start real pi sessions in tmux panes
|
||||
* - Poll for file creation and screen output
|
||||
* - Clean up surfaces and temp files after tests
|
||||
* - Clean up panes and temp files after tests
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getMuxBackend,
|
||||
isMuxAvailable,
|
||||
createSurface,
|
||||
createSurfaceSplit,
|
||||
sendCommand,
|
||||
@@ -31,14 +31,10 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
shellEscape,
|
||||
parseCmuxFocusedSnapshotFromJson,
|
||||
parseCmuxPaneRefForSurfaceFromJson,
|
||||
type MuxBackend,
|
||||
} from "../../pi-extension/subagents/cmux.ts";
|
||||
} from "../../pi-extension/subagents/tmux.ts";
|
||||
|
||||
// Re-export mux primitives for tests
|
||||
// Re-export tmux primitives for tests
|
||||
export {
|
||||
createSurface,
|
||||
createSurfaceSplit,
|
||||
@@ -47,10 +43,8 @@ export {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
shellEscape,
|
||||
};
|
||||
export type { MuxBackend };
|
||||
|
||||
// ── Paths ──
|
||||
|
||||
@@ -82,99 +76,42 @@ 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.
|
||||
* Detect whether tmux is available in the current environment.
|
||||
* Returns ["tmux"] or [].
|
||||
*/
|
||||
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 getAvailableBackends(): string[] {
|
||||
return isMuxAvailable() ? ["tmux"] : [];
|
||||
}
|
||||
|
||||
export function setBackend(backend: MuxBackend): string | undefined {
|
||||
const prev = process.env.PI_SUBAGENT_MUX;
|
||||
process.env.PI_SUBAGENT_MUX = backend;
|
||||
return prev;
|
||||
export function focusSurface(surface: string): void {
|
||||
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
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;
|
||||
export function getFocusedSurface(): string | null {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
if (getFocusedSurface() === surface) return;
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
|
||||
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
|
||||
`Timeout (${timeout}ms) waiting for focused tmux pane ${surface}; ` +
|
||||
`current focus is ${getFocusedSurface() ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,9 +120,7 @@ export async function waitForFocusedSurface(
|
||||
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) */
|
||||
/** Panes created during the test (cleaned up automatically) */
|
||||
surfaces: string[];
|
||||
/** Temp files to clean up */
|
||||
tempFiles: string[];
|
||||
@@ -195,7 +130,7 @@ export interface TestEnv {
|
||||
* 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 {
|
||||
export function createTestEnv(): TestEnv {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
|
||||
const agentsDir = join(dir, ".pi", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
@@ -209,7 +144,7 @@ export function createTestEnv(backend: MuxBackend): TestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
return { dir, backend, surfaces: [], tempFiles: [] };
|
||||
return { dir, surfaces: [], tempFiles: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +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 mux surface, runs pi with a task that uses the subagent
|
||||
* Each test creates a tmux pane, 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'
|
||||
* Run inside tmux:
|
||||
* tmux new 'npm run test:integration'
|
||||
*
|
||||
* Configuration:
|
||||
@@ -21,8 +20,6 @@ import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
getAvailableBackends,
|
||||
setBackend,
|
||||
restoreBackend,
|
||||
createTestEnv,
|
||||
cleanupTestEnv,
|
||||
createTrackedSurface,
|
||||
@@ -40,23 +37,20 @@ import {
|
||||
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.");
|
||||
console.log("⚠️ tmux is not available — skipping subagent lifecycle integration tests");
|
||||
console.log(" Run inside 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);
|
||||
env = createTestEnv();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanupTestEnv(env);
|
||||
restoreBackend(prevMux);
|
||||
});
|
||||
|
||||
// ── Basic spawn + completion ──
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
/**
|
||||
* Integration tests for the multiplexer surface layer.
|
||||
* Integration tests for the tmux surface layer.
|
||||
*
|
||||
* These tests exercise real mux operations: creating panes,
|
||||
* sending commands, reading screen output, and closing surfaces.
|
||||
* These tests exercise real tmux operations: creating panes,
|
||||
* sending commands, reading screen output, and closing panes.
|
||||
* No LLM calls — fast and free.
|
||||
*
|
||||
* Run inside a supported multiplexer:
|
||||
* cmux bash -c 'npm run test:integration'
|
||||
* Run inside tmux:
|
||||
* 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,
|
||||
@@ -31,7 +26,6 @@ import {
|
||||
readScreen,
|
||||
readScreenAsync,
|
||||
closeSurface,
|
||||
sendEscape,
|
||||
sleep,
|
||||
uniqueId,
|
||||
trackTempFile,
|
||||
@@ -44,47 +38,36 @@ 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.");
|
||||
console.log("⚠️ tmux is not available — skipping tmux-surface integration tests");
|
||||
console.log(" Run inside tmux to enable these tests.");
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
describe(`mux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
let prevMux: string | undefined;
|
||||
describe(`tmux-surface [${backend}]`, { timeout: 60_000 }, () => {
|
||||
let env: TestEnv;
|
||||
|
||||
before(() => {
|
||||
prevMux = setBackend(backend);
|
||||
env = createTestEnv(backend);
|
||||
env = createTestEnv();
|
||||
});
|
||||
|
||||
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);
|
||||
focusSurface(anchor);
|
||||
await waitForFocusedSurface(anchor, 10_000);
|
||||
|
||||
const childA = createTrackedSurface(env, "focus-child-a");
|
||||
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
||||
assert.equal(getFocusedSurface(backend), anchor);
|
||||
assert.equal(getFocusedSurface(), 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);
|
||||
}
|
||||
assert.equal(getFocusedSurface(), anchor);
|
||||
|
||||
const markerA = uniqueId();
|
||||
const markerB = uniqueId();
|
||||
@@ -95,7 +78,7 @@ for (const backend of backends) {
|
||||
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);
|
||||
assert.equal(getFocusedSurface(), anchor);
|
||||
});
|
||||
|
||||
it("creates a surface, sends a command, reads output, and closes it", async () => {
|
||||
@@ -197,7 +180,7 @@ for (const backend of backends) {
|
||||
await sleep(1000);
|
||||
|
||||
const marker = uniqueId();
|
||||
const filePath = `/tmp/pi-mux-test-${marker}.txt`;
|
||||
const filePath = `/tmp/pi-tmux-test-${marker}.txt`;
|
||||
|
||||
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
||||
|
||||
@@ -210,34 +193,5 @@ for (const backend of backends) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user