154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
import { describe, expect, it, spyOn } from "bun:test";
|
|
import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge";
|
|
import type { ToolSession } from "../src/tools";
|
|
import { BashTool } from "../src/tools/bash";
|
|
|
|
function makeSession(bridge: ClientBridge): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
skills: [],
|
|
getSessionFile: () => null,
|
|
settings: {
|
|
get(key: string) {
|
|
if (key === "async.enabled") return false;
|
|
if (key === "bash.autoBackground.enabled") return false;
|
|
if (key === "bash.autoBackground.thresholdMs") return 60_000;
|
|
if (key === "bashInterceptor.enabled") return false;
|
|
if (key === "astGrep.enabled") return false;
|
|
if (key === "astEdit.enabled") return false;
|
|
if (key === "search.enabled") return false;
|
|
if (key === "find.enabled") return false;
|
|
return undefined;
|
|
},
|
|
getBashInterceptorRules() {
|
|
return [];
|
|
},
|
|
},
|
|
getClientBridge: () => bridge,
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
describe("BashTool ACP terminal routing", () => {
|
|
it("routes through bridge, emits terminalId update, and releases the handle", async () => {
|
|
const stubText = "hello from terminal\n";
|
|
|
|
const handle: ClientBridgeTerminalHandle = {
|
|
terminalId: "term-xyz",
|
|
waitForExit: async () => ({ exitCode: 0, signal: null }),
|
|
currentOutput: async () => ({ output: stubText, truncated: false }),
|
|
kill: async () => {},
|
|
release: async () => {},
|
|
};
|
|
|
|
const bridge: ClientBridge = {
|
|
capabilities: { terminal: true },
|
|
createTerminal: async () => handle,
|
|
};
|
|
|
|
const createSpy = spyOn(bridge, "createTerminal");
|
|
const releaseSpy = spyOn(handle, "release");
|
|
|
|
const updates: Array<{ details?: { terminalId?: string } }> = [];
|
|
|
|
const tool = new BashTool(makeSession(bridge));
|
|
const result = await tool.execute("call-1", { command: "echo hi" }, undefined, update => {
|
|
updates.push(update as { details?: { terminalId?: string } });
|
|
});
|
|
|
|
// createTerminal must be called with the expanded command
|
|
expect(createSpy).toHaveBeenCalledTimes(1);
|
|
const params = createSpy.mock.calls[0]![0];
|
|
expect(params.command).toBe("echo hi");
|
|
|
|
// The first onUpdate must carry the terminalId so the editor can embed it
|
|
expect(updates.length).toBeGreaterThanOrEqual(1);
|
|
expect(updates[0]!.details?.terminalId).toBe("term-xyz");
|
|
|
|
// The final result text must contain the stub output
|
|
const text = result.content.find(c => c.type === "text");
|
|
expect(text?.text).toContain("hello from terminal");
|
|
|
|
// The result details must carry terminalId for the ACP event mapper
|
|
expect(result.details?.terminalId).toBe("term-xyz");
|
|
|
|
// The handle must always be released
|
|
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("releases the client terminal when final output retrieval fails", async () => {
|
|
const handle: ClientBridgeTerminalHandle = {
|
|
terminalId: "term-output-failure",
|
|
waitForExit: async () => ({ exitCode: 0, signal: null }),
|
|
currentOutput: async () => {
|
|
throw new Error("client output unavailable");
|
|
},
|
|
kill: async () => {},
|
|
release: async () => {},
|
|
};
|
|
const bridge: ClientBridge = {
|
|
capabilities: { terminal: true },
|
|
createTerminal: async () => handle,
|
|
};
|
|
const releaseSpy = spyOn(handle, "release");
|
|
|
|
const tool = new BashTool(makeSession(bridge));
|
|
|
|
await expect(tool.execute("call-output-failure", { command: "echo hi" })).rejects.toThrow(
|
|
/client output unavailable/,
|
|
);
|
|
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("releases the client terminal when waiting for exit fails", async () => {
|
|
const handle: ClientBridgeTerminalHandle = {
|
|
terminalId: "term-exit-failure",
|
|
waitForExit: async () => {
|
|
throw new Error("client wait unavailable");
|
|
},
|
|
currentOutput: async () => ({ output: "", truncated: false }),
|
|
kill: async () => {},
|
|
release: async () => {},
|
|
};
|
|
const bridge: ClientBridge = {
|
|
capabilities: { terminal: true },
|
|
createTerminal: async () => handle,
|
|
};
|
|
const releaseSpy = spyOn(handle, "release");
|
|
|
|
const tool = new BashTool(makeSession(bridge));
|
|
|
|
await expect(tool.execute("call-exit-failure", { command: "echo hi" })).rejects.toThrow(
|
|
/client wait unavailable/,
|
|
);
|
|
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("kills and releases the client terminal when the command times out", async () => {
|
|
const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>();
|
|
const handle: ClientBridgeTerminalHandle = {
|
|
terminalId: "term-timeout",
|
|
waitForExit: async () => pendingExit.promise,
|
|
currentOutput: async () => ({ output: "", truncated: false }),
|
|
kill: async () => {},
|
|
release: async () => {},
|
|
};
|
|
const bridge: ClientBridge = {
|
|
capabilities: { terminal: true },
|
|
createTerminal: async () => handle,
|
|
};
|
|
const killSpy = spyOn(handle, "kill");
|
|
const releaseSpy = spyOn(handle, "release");
|
|
|
|
const tool = new BashTool(makeSession(bridge));
|
|
|
|
await expect(tool.execute("call-timeout", { command: "sleep 60", timeout: 1 })).rejects.toThrow(
|
|
/Command timed out after 1 seconds/,
|
|
);
|
|
|
|
expect(killSpy).toHaveBeenCalledTimes(1);
|
|
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
|
pendingExit.resolve({ exitCode: null, signal: "TERM" });
|
|
});
|
|
});
|