Files
oh-my-pi/packages/coding-agent/test/bash-acp-terminal.test.ts
T
can1357 ae1650d689 refactor: renamed search and find tools to grep and glob
- Renamed the `find` and `search` tools to `glob` and `grep` respectively across the codebase to improve command clarity.
- Implemented full-stack support for the renamed tools, including CLI arguments, system prompts, SDK exports, and tool registration.
- Added automated migration logic in `settings` to transform legacy `find` and `search` configuration keys to their new equivalents.
- Updated the `collab-web` renderer registry to ensure backwards compatibility with legacy tool outputs.
2026-06-27 00:57:55 +02:00

160 lines
5.3 KiB
TypeScript

import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import type { ClientBridge, ClientBridgeTerminalHandle } from "@oh-my-pi/pi-coding-agent/session/client-bridge";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { BashTool } from "@oh-my-pi/pi-coding-agent/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 === "grep.enabled") return false;
if (key === "glob.enabled") return false;
return undefined;
},
getBashInterceptorRules() {
return [];
},
},
getClientBridge: () => bridge,
} as unknown as ToolSession;
}
afterEach(() => {
mock.restore();
});
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");
spyOn(Bun, "sleep").mockImplementation(async () => {});
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" });
});
});