feat: add bash pty diagnostics

This commit is contained in:
Peter Steinberger
2026-01-03 01:56:43 +00:00
parent a9eb31e8fe
commit fb10bf5f75
5 changed files with 136 additions and 9 deletions

View File

@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
describe("bash tool pty mode", () => {
it("falls back to pipe with warning when node-pty fails to load", async () => {
vi.resetModules();
vi.doMock("node-pty", () => {
throw new Error("boom");
});
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "echo test",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("Warning: node-pty failed to load");
expect(text).toContain("falling back to pipe mode.");
vi.doUnmock("node-pty");
});
it("uses node-pty when available", async () => {
vi.resetModules();
const spawn = vi.fn(() => {
let onData: ((data: string) => void) | undefined;
let onExit:
| ((event: { exitCode: number | null; signal?: number | null }) => void)
| undefined;
const pty = {
pid: 4321,
onData: (cb: (data: string) => void) => {
onData = cb;
},
onExit: (
cb: (event: { exitCode: number | null; signal?: number | null }) => void,
) => {
onExit = cb;
},
write: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
onData?.("hello\n");
onExit?.({ exitCode: 0, signal: null });
}, 10);
return pty;
});
vi.doMock("node-pty", () => ({ spawn }));
const { createBashTool } = await import("./bash-tools.js");
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
const result = await tool.execute("call", {
command: "ignored",
stdinMode: "pty",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hello");
expect(text).not.toContain("Warning:");
vi.doUnmock("node-pty");
});
});

View File

@@ -34,13 +34,14 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
let ptyModulePromise: Promise<PtyModule | null> | null = null;
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
async function loadPtyModule(): Promise<PtyModule | null> {
async function loadPtyModule(): Promise<PtyLoadResult> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => mod)
.catch(() => null);
.then((mod) => ({ module: mod }))
.catch((error) => ({ module: null, error }));
}
return ptyModulePromise;
}
@@ -166,10 +167,11 @@ export function createBashTool(
let pty: IPty | undefined;
if (stdinMode === "pty") {
const ptyModule = await loadPtyModule();
const { module: ptyModule, error: ptyError } = await loadPtyModule();
if (!ptyModule) {
warning =
"Warning: node-pty failed to load; falling back to pipe mode.";
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
} else {
const ptyEnv = {
@@ -184,9 +186,10 @@ export function createBashTool(
cols: 120,
rows: 30,
});
} catch {
} catch (error) {
warning =
"Warning: node-pty failed to start; falling back to pipe mode.";
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
"falling back to pipe mode.";
stdinMode = "pipe";
}
}
@@ -886,6 +889,20 @@ function killSession(session: {
}
}
function formatPtyError(error: unknown) {
if (!error) return "";
if (typeof error === "string") return ` (${error})`;
if (error instanceof Error) {
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
return firstLine ? ` (${firstLine})` : "";
}
try {
return ` (${JSON.stringify(error)})`;
} catch {
return "";
}
}
function clampNumber(
value: number | undefined,
defaultValue: number,