Files
clawdbot/src/agents/bash-tools.test.ts
2026-01-09 15:40:02 +01:00

244 lines
7.8 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import {
bashTool,
createBashTool,
createProcessTool,
processTool,
} from "./bash-tools.js";
import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32";
const shortDelayCmd = isWin ? "ping -n 2 127.0.0.1 > nul" : "sleep 0.05";
const yieldDelayCmd = isWin ? "ping -n 3 127.0.0.1 > nul" : "sleep 0.2";
const longDelayCmd = isWin ? "ping -n 4 127.0.0.1 > nul" : "sleep 2";
const joinCommands = (commands: string[]) =>
commands.join(isWin ? " & " : "; ");
const echoAfterDelay = (message: string) =>
joinCommands([shortDelayCmd, `echo ${message}`]);
const echoLines = (lines: string[]) =>
joinCommands(lines.map((line) => `echo ${line}`));
const normalizeText = (value?: string) =>
sanitizeBinaryOutput(value ?? "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.replace(/\s+$/u, ""))
.join("\n")
.trim();
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForCompletion(sessionId: string) {
let status = "running";
const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call-wait", {
action: "poll",
sessionId,
});
status = (poll.details as { status: string }).status;
if (status === "running") {
await sleep(20);
}
}
return status;
}
beforeEach(() => {
resetProcessRegistryForTests();
});
describe("bash tool backgrounding", () => {
it(
"backgrounds after yield and can be polled",
async () => {
const result = await bashTool.execute("call1", {
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
let output = "";
const deadline =
Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
action: "poll",
sessionId,
});
status = (poll.details as { status: string }).status;
const textBlock = poll.content.find((c) => c.type === "text");
output = textBlock?.text ?? "";
if (status === "running") {
await sleep(20);
}
}
expect(status).toBe("completed");
expect(output).toContain("done");
},
isWin ? 15_000 : 5_000,
);
it("supports explicit background", async () => {
const result = await bashTool.execute("call1", {
command: echoAfterDelay("later"),
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
const list = await processTool.execute("call2", { action: "list" });
const sessions = (
list.details as { sessions: Array<{ sessionId: string }> }
).sessions;
expect(sessions.some((s) => s.sessionId === sessionId)).toBe(true);
});
it("derives a session name from the command", async () => {
const result = await bashTool.execute("call1", {
command: "echo hello",
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await sleep(25);
const list = await processTool.execute("call2", { action: "list" });
const sessions = (
list.details as { sessions: Array<{ sessionId: string; name?: string }> }
).sessions;
const entry = sessions.find((s) => s.sessionId === sessionId);
expect(entry?.name).toBe("echo hello");
});
it("uses default timeout when timeout is omitted", async () => {
const customBash = createBashTool({ timeoutSec: 1, backgroundMs: 10 });
const customProcess = createProcessTool();
const result = await customBash.execute("call1", {
command: longDelayCmd,
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
const deadline = Date.now() + 5000;
while (Date.now() < deadline && status === "running") {
const poll = await customProcess.execute("call2", {
action: "poll",
sessionId,
});
status = (poll.details as { status: string }).status;
if (status === "running") {
await sleep(50);
}
}
expect(status).toBe("failed");
});
it("rejects elevated requests when not allowed", async () => {
const customBash = createBashTool({
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
});
await expect(
customBash.execute("call1", {
command: "echo hi",
elevated: true,
}),
).rejects.toThrow("elevated is not available right now.");
});
it("does not default to elevated when not allowed", async () => {
const customBash = createBashTool({
elevated: { enabled: true, allowed: false, defaultLevel: "on" },
backgroundMs: 1000,
timeoutSec: 5,
});
const result = await customBash.execute("call1", {
command: "echo hi",
});
const text = result.content.find((c) => c.type === "text")?.text ?? "";
expect(text).toContain("hi");
});
it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", {
command: echoLines(["one", "two", "three"]),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
const status = await waitForCompletion(sessionId);
const log = await processTool.execute("call3", {
action: "log",
sessionId,
limit: 2,
});
const textBlock = log.content.find((c) => c.type === "text");
expect(normalizeText(textBlock?.text)).toBe("two\nthree");
expect((log.details as { totalLines?: number }).totalLines).toBe(3);
expect(status).toBe("completed");
});
it("supports line offsets for log slices", async () => {
const result = await bashTool.execute("call1", {
command: echoLines(["alpha", "beta", "gamma"]),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const log = await processTool.execute("call2", {
action: "log",
sessionId,
offset: 1,
limit: 1,
});
const textBlock = log.content.find((c) => c.type === "text");
expect(normalizeText(textBlock?.text)).toBe("beta");
});
it("scopes process sessions by scopeKey", async () => {
const bashA = createBashTool({ backgroundMs: 10, scopeKey: "agent:alpha" });
const processA = createProcessTool({ scopeKey: "agent:alpha" });
const bashB = createBashTool({ backgroundMs: 10, scopeKey: "agent:beta" });
const processB = createProcessTool({ scopeKey: "agent:beta" });
const resultA = await bashA.execute("call1", {
command: shortDelayCmd,
background: true,
});
const resultB = await bashB.execute("call2", {
command: shortDelayCmd,
background: true,
});
const sessionA = (resultA.details as { sessionId: string }).sessionId;
const sessionB = (resultB.details as { sessionId: string }).sessionId;
const listA = await processA.execute("call3", { action: "list" });
const sessionsA = (
listA.details as { sessions: Array<{ sessionId: string }> }
).sessions;
expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true);
expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false);
const pollB = await processB.execute("call4", {
action: "poll",
sessionId: sessionA,
});
expect(pollB.details.status).toBe("failed");
});
});