| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import type { ProcessSession } from "./bash-process-registry.js"; |
| import { |
| addSession, |
| getFinishedSession, |
| getSession, |
| resetProcessRegistryForTests, |
| } from "./bash-process-registry.js"; |
| import { createProcessTool } from "./bash-tools.process.js"; |
|
|
| const { supervisorMock } = vi.hoisted(() => ({ |
| supervisorMock: { |
| spawn: vi.fn(), |
| cancel: vi.fn(), |
| cancelScope: vi.fn(), |
| reconcileOrphans: vi.fn(), |
| getRecord: vi.fn(), |
| }, |
| })); |
|
|
| const { killProcessTreeMock } = vi.hoisted(() => ({ |
| killProcessTreeMock: vi.fn(), |
| })); |
|
|
| vi.mock("../process/supervisor/index.js", () => ({ |
| getProcessSupervisor: () => supervisorMock, |
| })); |
|
|
| vi.mock("../process/kill-tree.js", () => ({ |
| killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), |
| })); |
|
|
| function createBackgroundSession(id: string, pid?: number): ProcessSession { |
| return { |
| id, |
| command: "sleep 999", |
| startedAt: Date.now(), |
| cwd: "/tmp", |
| maxOutputChars: 10_000, |
| pendingMaxOutputChars: 30_000, |
| totalOutputChars: 0, |
| pendingStdout: [], |
| pendingStderr: [], |
| pendingStdoutChars: 0, |
| pendingStderrChars: 0, |
| aggregated: "", |
| tail: "", |
| pid, |
| exited: false, |
| exitCode: undefined, |
| exitSignal: undefined, |
| truncated: false, |
| backgrounded: true, |
| }; |
| } |
|
|
| describe("process tool supervisor cancellation", () => { |
| beforeEach(() => { |
| supervisorMock.spawn.mockReset(); |
| supervisorMock.cancel.mockReset(); |
| supervisorMock.cancelScope.mockReset(); |
| supervisorMock.reconcileOrphans.mockReset(); |
| supervisorMock.getRecord.mockReset(); |
| killProcessTreeMock.mockReset(); |
| }); |
|
|
| afterEach(() => { |
| resetProcessRegistryForTests(); |
| }); |
|
|
| it("routes kill through supervisor when run is managed", async () => { |
| supervisorMock.getRecord.mockReturnValue({ |
| runId: "sess", |
| state: "running", |
| }); |
| addSession(createBackgroundSession("sess")); |
| const processTool = createProcessTool(); |
|
|
| const result = await processTool.execute("toolcall", { |
| action: "kill", |
| sessionId: "sess", |
| }); |
|
|
| expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); |
| expect(getSession("sess")).toBeDefined(); |
| expect(getSession("sess")?.exited).toBe(false); |
| expect(result.content[0]).toMatchObject({ |
| type: "text", |
| text: "Termination requested for session sess.", |
| }); |
| }); |
|
|
| it("remove drops running session immediately when cancellation is requested", async () => { |
| supervisorMock.getRecord.mockReturnValue({ |
| runId: "sess", |
| state: "running", |
| }); |
| addSession(createBackgroundSession("sess")); |
| const processTool = createProcessTool(); |
|
|
| const result = await processTool.execute("toolcall", { |
| action: "remove", |
| sessionId: "sess", |
| }); |
|
|
| expect(supervisorMock.cancel).toHaveBeenCalledWith("sess", "manual-cancel"); |
| expect(getSession("sess")).toBeUndefined(); |
| expect(getFinishedSession("sess")).toBeUndefined(); |
| expect(result.content[0]).toMatchObject({ |
| type: "text", |
| text: "Removed session sess (termination requested).", |
| }); |
| }); |
|
|
| it("falls back to process-tree kill when supervisor record is missing", async () => { |
| supervisorMock.getRecord.mockReturnValue(undefined); |
| addSession(createBackgroundSession("sess-fallback", 4242)); |
| const processTool = createProcessTool(); |
|
|
| const result = await processTool.execute("toolcall", { |
| action: "kill", |
| sessionId: "sess-fallback", |
| }); |
|
|
| expect(killProcessTreeMock).toHaveBeenCalledWith(4242); |
| expect(getSession("sess-fallback")).toBeUndefined(); |
| expect(getFinishedSession("sess-fallback")).toBeDefined(); |
| expect(result.content[0]).toMatchObject({ |
| type: "text", |
| text: "Killed session sess-fallback.", |
| }); |
| }); |
|
|
| it("fails remove when no supervisor record and no pid is available", async () => { |
| supervisorMock.getRecord.mockReturnValue(undefined); |
| addSession(createBackgroundSession("sess-no-pid")); |
| const processTool = createProcessTool(); |
|
|
| const result = await processTool.execute("toolcall", { |
| action: "remove", |
| sessionId: "sess-no-pid", |
| }); |
|
|
| expect(killProcessTreeMock).not.toHaveBeenCalled(); |
| expect(getSession("sess-no-pid")).toBeDefined(); |
| expect(result.details).toMatchObject({ status: "failed" }); |
| expect(result.content[0]).toMatchObject({ |
| type: "text", |
| text: "Unable to remove session sess-no-pid: no active supervisor run or process id.", |
| }); |
| }); |
| }); |
|
|