summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-shell/src/shell.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tool-shell/src/shell.test.ts')
-rw-r--r--packages/tool-shell/src/shell.test.ts298
1 files changed, 123 insertions, 175 deletions
diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts
index 07e0af4..4a579b0 100644
--- a/packages/tool-shell/src/shell.test.ts
+++ b/packages/tool-shell/src/shell.test.ts
@@ -1,12 +1,7 @@
+import { type ExecBackend, localExecBackend } from "@dispatch/exec-backend";
import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
import { describe, expect, it } from "vitest";
-import {
- buildResult,
- createRunShellTool,
- type SpawnShell,
- truncateOutput,
- validateArgs,
-} from "./shell.js";
+import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
return {
@@ -22,12 +17,21 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
};
}
-function fakeSpawn(result: {
+/** A fake backend whose `spawn` resolves a fixed result (no real I/O). */
+function fakeBackend(result: {
exitCode: number | null;
timedOut: boolean;
aborted?: boolean;
-}): SpawnShell {
- return async () => ({ aborted: false, ...result });
+}): ExecBackend {
+ return {
+ ...localExecBackend,
+ spawn: async () => ({ aborted: false, ...result }),
+ };
+}
+
+/** Wrap a fake backend in the resolver the factory expects (computerId-agnostic). */
+function resolverFor(backend: ExecBackend) {
+ return () => backend;
}
describe("validateArgs", () => {
@@ -173,7 +177,7 @@ describe("createRunShellTool", () => {
it("has correct name and parameters shape", () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
});
expect(tool.name).toBe("run_shell");
expect(tool.parameters.type).toBe("object");
@@ -185,7 +189,7 @@ describe("createRunShellTool", () => {
it("concurrencySafe is false", () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
});
expect(tool.concurrencySafe).toBe(false);
});
@@ -193,7 +197,7 @@ describe("createRunShellTool", () => {
it("rejects missing or empty command", async () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: fakeSpawn({ exitCode: 0, timedOut: false }),
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
});
const result = await tool.execute({}, stubCtx());
expect(result.isError).toBe(true);
@@ -203,10 +207,13 @@ describe("createRunShellTool", () => {
it("maps a zero exit code to a success result", async () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async (_params) => {
- _params.onOutput("hello\n", "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("hello\n", "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
const result = await tool.execute({ command: "echo hello" }, stubCtx());
expect(result.isError).toBeUndefined();
@@ -216,10 +223,13 @@ describe("createRunShellTool", () => {
it("maps a non-zero exit code to an isError result", async () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async (_params) => {
- _params.onOutput("error output\n", "stderr");
- return { exitCode: 1, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("error output\n", "stderr");
+ return { exitCode: 1, timedOut: false, aborted: false };
+ },
+ }),
});
const result = await tool.execute({ command: "false" }, stubCtx());
expect(result.isError).toBe(true);
@@ -229,10 +239,13 @@ describe("createRunShellTool", () => {
it("reports a timeout as an isError result", async () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async (_params) => {
- _params.onOutput("partial\n", "stdout");
- return { exitCode: null, timedOut: true, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("partial\n", "stdout");
+ return { exitCode: null, timedOut: true, aborted: false };
+ },
+ }),
});
const result = await tool.execute({ command: "sleep 999" }, stubCtx());
expect(result.isError).toBe(true);
@@ -244,10 +257,13 @@ describe("createRunShellTool", () => {
const tool = createRunShellTool({
workdir: "/tmp",
outputCap: cap,
- spawn: async (_params) => {
- _params.onOutput("a".repeat(200), "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("a".repeat(200), "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
const result = await tool.execute({ command: "gen" }, stubCtx());
expect(result.content).toContain("[Output truncated");
@@ -258,12 +274,15 @@ describe("createRunShellTool", () => {
const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async (params) => {
- params.onOutput("line1\n", "stdout");
- params.onOutput("err1\n", "stderr");
- params.onOutput("line2\n", "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("line1\n", "stdout");
+ params.onOutput("err1\n", "stderr");
+ params.onOutput("line2\n", "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
await tool.execute(
{ command: "test" },
@@ -282,10 +301,13 @@ describe("createRunShellTool", () => {
let receivedCwd = "";
const tool = createRunShellTool({
workdir: "/baked",
- spawn: async (params) => {
- receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" }));
expect(receivedCwd).toBe("/custom");
@@ -295,10 +317,13 @@ describe("createRunShellTool", () => {
let receivedCwd = "";
const tool = createRunShellTool({
workdir: "/baked",
- spawn: async (params) => {
- receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
await tool.execute({ command: "pwd" }, stubCtx());
expect(receivedCwd).toBe("/baked");
@@ -307,9 +332,12 @@ describe("createRunShellTool", () => {
it("returns error for spawn failure", async () => {
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async () => {
- throw new Error("spawn failed");
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async () => {
+ throw new Error("spawn failed");
+ },
+ }),
});
const result = await tool.execute({ command: "bad" }, stubCtx());
expect(result.isError).toBe(true);
@@ -321,7 +349,7 @@ describe("createRunShellTool", () => {
controller.abort();
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }),
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
});
const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal }));
expect(result.isError).toBe(true);
@@ -331,20 +359,51 @@ describe("createRunShellTool", () => {
let receivedTimeout = 0;
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async (params) => {
- receivedTimeout = params.timeout;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedTimeout = params.timeout;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
});
await tool.execute({ command: "test", timeout: 5000 }, stubCtx());
expect(receivedTimeout).toBe(5000);
});
+
+ it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => {
+ let receivedComputerId: string | undefined = "__unset__";
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: (computerId) => {
+ receivedComputerId = computerId;
+ return fakeBackend({ exitCode: 0, timedOut: false });
+ },
+ });
+ await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" }));
+ expect(receivedComputerId).toBe("my-host");
+ });
+
+ it("resolves the local backend when ctx.computerId is undefined", async () => {
+ let receivedComputerId: string | undefined = "__unset__";
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: (computerId) => {
+ receivedComputerId = computerId;
+ return fakeBackend({ exitCode: 0, timedOut: false });
+ },
+ });
+ await tool.execute({ command: "test" }, stubCtx());
+ expect(receivedComputerId).toBeUndefined();
+ });
});
describe("createRunShellTool (integration)", () => {
- it("runs a real echo command and captures stdout + cwd", async () => {
- const { realSpawn } = await import("./spawn.js");
- const tool = createRunShellTool({ workdir: "/tmp", spawn: realSpawn });
+ it("runs a real echo command through the local backend and captures stdout + cwd", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: () => localExecBackend,
+ });
let streamed = "";
const result = await tool.execute(
{ command: "echo hello-from-shell" },
@@ -358,134 +417,23 @@ describe("createRunShellTool (integration)", () => {
expect(result.content).toContain("hello-from-shell");
expect(streamed).toContain("hello-from-shell");
});
-});
-describe("realSpawn — process-group kill on abort/timeout", () => {
- it("aborts a command with a grandchild holding the pipes and resolves immediately", async () => {
- const { realSpawn } = await import("./spawn.js");
+ it("aborts a real long-running command through the local backend and resolves with aborted", async () => {
const controller = new AbortController();
-
- // "sleep 30 & wait" spawns a grandchild (sleep) that inherits the stdio
- // pipes. Killing just the sh parent does NOT close the pipes → close never
- // fires. With detached:true + process-group kill, the grandchild dies too.
- const promise = realSpawn({
- command: "sleep 30 & wait",
- cwd: "/tmp",
- signal: controller.signal,
- timeout: 60_000,
- onOutput: () => {},
- });
-
- // Give the shell time to actually spawn the grandchild.
- await new Promise((r) => setTimeout(r, 500));
-
- controller.abort();
-
- // Must resolve promptly (not wait 30s for the grandchild's sleep).
- const result = await promise;
- expect(result.aborted).toBe(true);
- expect(result.timedOut).toBe(false);
-
- // Give the OS a moment to reap the killed processes.
- await new Promise((r) => setTimeout(r, 200));
-
- // The grandchild sleep process should be gone. Check via pgrep.
- const { execSync } = await import("node:child_process");
- let sleeping: string[];
- try {
- sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
- } catch {
- // pgrep returns non-zero when no processes match → all gone.
- sleeping = [];
- }
- expect(sleeping.length).toBe(0);
- });
-
- it("times out a command with a grandchild holding the pipes and resolves promptly", async () => {
- const { realSpawn } = await import("./spawn.js");
- const controller = new AbortController();
-
- const promise = realSpawn({
- command: "sleep 30 & wait",
- cwd: "/tmp",
- signal: controller.signal,
- timeout: 500,
- onOutput: () => {},
- });
-
- // Must resolve within a short window (not 30s).
- const start = Date.now();
- const result = await promise;
- const elapsed = Date.now() - start;
-
- expect(result.timedOut).toBe(true);
- expect(result.aborted).toBe(false);
- // Should resolve shortly after the 500ms timeout, well under 30s.
- expect(elapsed).toBeLessThan(10_000);
-
- // Grandchild should be dead.
- await new Promise((r) => setTimeout(r, 200));
- const { execSync } = await import("node:child_process");
- let sleeping: string[];
- try {
- sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
- } catch {
- sleeping = [];
- }
- expect(sleeping.length).toBe(0);
- });
-
- it("captures stdout on normal completion (regression guard)", async () => {
- const { realSpawn } = await import("./spawn.js");
- const controller = new AbortController();
- let output = "";
-
- const result = await realSpawn({
- command: "echo hi",
- cwd: "/tmp",
- signal: controller.signal,
- timeout: 5_000,
- onOutput: (data) => {
- output += data;
- },
- });
-
- expect(result.aborted).toBe(false);
- expect(result.timedOut).toBe(false);
- expect(result.exitCode).toBe(0);
- expect(output).toContain("hi");
- });
-
- it("aborts a simple single-process command and resolves with aborted: true", async () => {
- const { realSpawn } = await import("./spawn.js");
- const controller = new AbortController();
-
- const promise = realSpawn({
- command: "sleep 30",
- cwd: "/tmp",
- signal: controller.signal,
- timeout: 60_000,
- onOutput: () => {},
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: () => localExecBackend,
});
-
+ const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal }));
// Let the sleep actually start.
await new Promise((r) => setTimeout(r, 300));
-
controller.abort();
-
const result = await promise;
- expect(result.aborted).toBe(true);
- expect(result.timedOut).toBe(false);
-
- // The sleep process should be gone.
+ expect(result.isError).toBe(true);
+ // The detailed process-group-kill semantics (grandchild holding the pipes,
+ // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts —
+ // realSpawn was ported there byte-for-byte. Here we only confirm the tool
+ // wires the backend's aborted result through to an isError result.
await new Promise((r) => setTimeout(r, 200));
- const { execSync } = await import("node:child_process");
- let sleeping: string[];
- try {
- sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
- } catch {
- sleeping = [];
- }
- expect(sleeping.length).toBe(0);
});
});