diff --git a/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts b/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts new file mode 100644 index 00000000000..c59570f2f46 --- /dev/null +++ b/src/agents/bash-tools.exec-runtime.pty-fallback.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest"; +import type { ManagedRun, SpawnInput } from "../process/supervisor/index.js"; + +let listRunningSessions: typeof import("./bash-process-registry.js").listRunningSessions; +let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").resetProcessRegistryForTests; +let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess; + +const { supervisorSpawnMock } = vi.hoisted(() => ({ + supervisorSpawnMock: vi.fn(), +})); + +vi.mock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: supervisorSpawnMock, + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), +})); + +function createSuccessfulRun(input: SpawnInput): ManagedRun { + input.onStdout?.("ok"); + return { + runId: input.runId ?? "test-run", + pid: 1234, + startedAtMs: Date.now(), + stdin: { + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }, + cancel: vi.fn(), + wait: vi.fn(async () => ({ + reason: "exit" as const, + exitCode: 0, + exitSignal: null, + durationMs: 1, + stdout: "", + stderr: "", + timedOut: false, + noOutputTimedOut: false, + })), + }; +} + +beforeAll(async () => { + ({ listRunningSessions, resetProcessRegistryForTests } = + await import("./bash-process-registry.js")); + ({ runExecProcess } = await import("./bash-tools.exec-runtime.js")); +}); + +beforeEach(() => { + supervisorSpawnMock.mockReset(); +}); + +afterEach(() => { + resetProcessRegistryForTests(); + vi.clearAllMocks(); +}); + +function runPtyFallback(warnings: string[] = []) { + return runExecProcess({ + command: "printf ok", + workdir: process.cwd(), + env: {}, + usePty: true, + warnings, + maxOutput: 20_000, + pendingMaxOutput: 20_000, + notifyOnExit: false, + timeoutSec: 5, + }); +} + +test("exec falls back when PTY spawn fails", async () => { + supervisorSpawnMock + .mockRejectedValueOnce(new Error("pty spawn failed")) + .mockImplementationOnce(async (input: SpawnInput) => createSuccessfulRun(input)); + + const warnings: string[] = []; + const handle = await runPtyFallback(warnings); + const outcome = await handle.promise; + + expect(outcome.status).toBe("completed"); + expect(outcome.aggregated).toContain("ok"); + expect(warnings.join("\n")).toContain("PTY spawn failed"); + expect(supervisorSpawnMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ mode: "pty" })); + expect(supervisorSpawnMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ mode: "child" }), + ); +}); + +test("exec cleans session state when PTY fallback spawn also fails", async () => { + supervisorSpawnMock + .mockRejectedValueOnce(new Error("pty spawn failed")) + .mockRejectedValueOnce(new Error("child fallback failed")); + + await expect(runPtyFallback()).rejects.toThrow("child fallback failed"); + + expect(listRunningSessions()).toHaveLength(0); +}); diff --git a/src/agents/bash-tools.exec.pty-fallback-failure.test.ts b/src/agents/bash-tools.exec.pty-fallback-failure.test.ts deleted file mode 100644 index bc1e9761d98..00000000000 --- a/src/agents/bash-tools.exec.pty-fallback-failure.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest"; -let createExecTool: typeof import("./bash-tools.exec.js").createExecTool; -let listRunningSessions: typeof import("./bash-process-registry.js").listRunningSessions; -let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").resetProcessRegistryForTests; - -const { supervisorSpawnMock } = vi.hoisted(() => ({ - supervisorSpawnMock: vi.fn(), -})); - -const makeSupervisor = () => { - const noop = vi.fn(); - return { - spawn: (...args: unknown[]) => supervisorSpawnMock(...args), - cancel: noop, - cancelScope: noop, - reconcileOrphans: noop, - getRecord: noop, - }; -}; - -vi.mock("../process/supervisor/index.js", () => ({ - getProcessSupervisor: () => makeSupervisor(), -})); - -beforeAll(async () => { - ({ createExecTool } = await import("./bash-tools.exec.js")); - ({ listRunningSessions, resetProcessRegistryForTests } = - await import("./bash-process-registry.js")); -}); - -beforeEach(() => { - supervisorSpawnMock.mockReset(); -}); - -afterEach(() => { - resetProcessRegistryForTests(); - vi.clearAllMocks(); -}); - -test("exec cleans session state when PTY fallback spawn also fails", async () => { - supervisorSpawnMock - .mockRejectedValueOnce(new Error("pty spawn failed")) - .mockRejectedValueOnce(new Error("child fallback failed")); - - const tool = createExecTool({ - allowBackground: false, - host: "gateway", - security: "full", - ask: "off", - }); - - await expect( - tool.execute("toolcall", { - command: "echo ok", - pty: true, - }), - ).rejects.toThrow("child fallback failed"); - - expect(listRunningSessions()).toHaveLength(0); -}); diff --git a/src/agents/bash-tools.exec.pty-fallback.test.ts b/src/agents/bash-tools.exec.pty-fallback.test.ts deleted file mode 100644 index 6a6b74f63bc..00000000000 --- a/src/agents/bash-tools.exec.pty-fallback.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, beforeAll, expect, test, vi } from "vitest"; -let createExecTool: typeof import("./bash-tools.exec.js").createExecTool; -let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").resetProcessRegistryForTests; - -vi.mock("@lydell/node-pty", () => ({ - spawn: () => { - const err = new Error("spawn EBADF"); - (err as NodeJS.ErrnoException).code = "EBADF"; - throw err; - }, -})); - -beforeAll(async () => { - ({ createExecTool } = await import("./bash-tools.exec.js")); - ({ resetProcessRegistryForTests } = await import("./bash-process-registry.js")); -}); - -afterEach(() => { - resetProcessRegistryForTests(); - vi.clearAllMocks(); -}); - -test("exec falls back when PTY spawn fails", async () => { - const tool = createExecTool({ - allowBackground: false, - host: "gateway", - security: "full", - ask: "off", - }); - const result = await tool.execute("toolcall", { - command: "printf ok", - pty: true, - }); - - expect(result.details.status).toBe("completed"); - const text = result.content?.find((item) => item.type === "text")?.text ?? ""; - expect(text).toContain("ok"); - expect(text).toContain("PTY spawn failed"); -});