Files
openclaw/src/agents/bash-tools.exec-host-node-phases.test.ts
T
Peter Steinberger b671c1bfe7 test: consolidate Bash tools suites (#117856)
* test: consolidate bash tools suites

* test: satisfy consolidated suite checks
2026-08-01 23:21:52 -07:00

194 lines
5.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
formatNodeInvokeFailureFollowup,
invokeNodeSystemRun,
} from "./bash-tools.exec-host-node-failure.js";
import { invokeNodeSystemRunDirect } from "./bash-tools.exec-host-node-phases.js";
const callGatewayToolMock = vi.hoisted(() => vi.fn());
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: callGatewayToolMock,
}));
function gatewayNodeInvokeError(params: {
code?: string;
message?: string;
nodeCommandDispatched?: boolean;
requestSent?: boolean;
}): Error {
return Object.assign(new Error(params.message ?? "node invoke failed"), {
name: "GatewayClientRequestError",
gatewayCode: "UNAVAILABLE",
details: {
nodeError: {
...(params.code ? { code: params.code } : {}),
message: params.message ?? "node invoke failed",
},
...(params.nodeCommandDispatched !== undefined
? { nodeCommandDispatched: params.nodeCommandDispatched }
: {}),
},
...(params.requestSent !== undefined ? { requestSent: params.requestSent } : {}),
});
}
async function invokeFailure(error: unknown) {
callGatewayToolMock.mockRejectedValueOnce(error);
const result = await invokeNodeSystemRun({
invokeWaitMs: 1_000,
invoke: { nodeId: "node-1", command: "system.run" },
});
if (result.ok) {
throw new Error("expected node invoke failure");
}
return result.failure;
}
describe("invokeNodeSystemRun failure classification", () => {
it("classifies only proven pre-dispatch NOT_CONNECTED as retry-safe", async () => {
await expect(
invokeFailure(
gatewayNodeInvokeError({
code: "NOT_CONNECTED",
message: "node not connected",
nodeCommandDispatched: false,
requestSent: true,
}),
),
).resolves.toEqual({
reason: "not-dispatched",
retrySafe: true,
code: "NOT_CONNECTED",
message: "node not connected",
nodeCommandDispatched: false,
requestSent: true,
});
});
it.each([
{
name: "deadline before dispatch",
error: gatewayNodeInvokeError({
code: "TIMEOUT",
nodeCommandDispatched: false,
}),
},
{
name: "disconnect after dispatch",
error: gatewayNodeInvokeError({
code: "DISCONNECTED",
nodeCommandDispatched: true,
}),
},
{
name: "missing dispatch provenance",
error: gatewayNodeInvokeError({ code: "NOT_CONNECTED" }),
},
{
name: "client timeout before request bytes crossed send",
error: Object.assign(new Error("gateway request timeout for node.invoke"), {
name: "GatewayClientRequestTimeoutError",
requestSent: false,
}),
},
{
name: "malformed failure",
error: { message: 42 },
},
])("classifies $name as outcome-unknown", async ({ error }) => {
await expect(invokeFailure(error)).resolves.toMatchObject({
reason: "outcome-unknown",
retrySafe: false,
});
});
it("preserves multiline command metadata in an outcome-unknown followup", async () => {
const failure = await invokeFailure(
gatewayNodeInvokeError({
code: "TIMEOUT",
message: "node invoke timed out",
nodeCommandDispatched: true,
}),
);
const text = formatNodeInvokeFailureFollowup({
failure,
nodeId: "node-1",
approvalId: "approval-1",
command: "printf 'one\\ntwo'\necho done",
});
expect(text).toContain("Exec outcome unknown (node=node-1 id=approval-1, outcome-unknown)");
expect(text).toContain("The command may have executed. Do not rerun it automatically.");
expect(text).toContain("Command:\nprintf 'one\\ntwo'\necho done");
});
});
type DirectNodeRun = Parameters<typeof invokeNodeSystemRunDirect>[0];
function createDirectNodeRun(signal?: AbortSignal): DirectNodeRun {
return {
request: {
command: "tool --version",
workdir: "/tmp/work",
env: {},
security: "full",
ask: "off",
defaultTimeoutSec: 30,
approvalRunningNoticeMs: 0,
warnings: [],
...(signal ? { signal } : {}),
},
target: {
nodeId: "node-1",
argv: ["tool", "--version"],
env: undefined,
invokeDeadlineMs: 30_000,
invokeWaitMs: 35_000,
runTimeoutSec: 30,
supportsSystemRunPrepare: true,
},
};
}
describe("direct node run cancellation", () => {
beforeEach(() => {
callGatewayToolMock.mockReset();
callGatewayToolMock.mockResolvedValue({
payload: { success: true, stdout: "ok", stderr: "", exitCode: 0 },
});
});
it.each([
{ name: "with its original cancellation signal", withSignal: true },
{ name: "without a signal argument", withSignal: false },
])("forwards the gateway call $name", async ({ withSignal }) => {
const controller = new AbortController();
await invokeNodeSystemRunDirect(
createDirectNodeRun(withSignal ? controller.signal : undefined),
);
const baseArgs = [
"node.invoke",
{ timeoutMs: 35_000 },
expect.objectContaining({ command: "system.run" }),
] as const;
if (withSignal) {
expect(callGatewayToolMock).toHaveBeenCalledWith(...baseArgs, { signal: controller.signal });
} else {
expect(callGatewayToolMock).toHaveBeenCalledWith(...baseArgs);
}
});
it("never dispatches a direct node run after cancellation", async () => {
const controller = new AbortController();
const reason = new Error("cancelled before direct node dispatch");
controller.abort(reason);
await expect(invokeNodeSystemRunDirect(createDirectNodeRun(controller.signal))).rejects.toBe(
reason,
);
expect(callGatewayToolMock).not.toHaveBeenCalled();
});
});