diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/tool-shell/src | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip | |
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/tool-shell/src')
| -rw-r--r-- | packages/tool-shell/src/extension.ts | 32 | ||||
| -rw-r--r-- | packages/tool-shell/src/shell.test.ts | 816 | ||||
| -rw-r--r-- | packages/tool-shell/src/shell.ts | 298 |
3 files changed, 573 insertions, 573 deletions
diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts index 984263e..74e0ac9 100644 --- a/packages/tool-shell/src/extension.ts +++ b/packages/tool-shell/src/extension.ts @@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel"; import { createRunShellTool } from "./shell.js"; export const extension: Extension = { - manifest: { - id: "tool-shell", - name: "Shell Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { shell: true }, - contributes: { tools: ["run_shell"] }, - // Host activates exec-backend first → host.getService at activation is safe. - dependsOn: ["exec-backend"], - }, - activate(host) { - const resolveBackend = host.getService(execBackendHandle); - host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend })); - }, + manifest: { + id: "tool-shell", + name: "Shell Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { shell: true }, + contributes: { tools: ["run_shell"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], + }, + activate(host) { + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend })); + }, }; diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts index 4a579b0..42127e6 100644 --- a/packages/tool-shell/src/shell.test.ts +++ b/packages/tool-shell/src/shell.test.ts @@ -4,436 +4,436 @@ import { describe, expect, it } from "vitest"; import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } /** A fake backend whose `spawn` resolves a fixed result (no real I/O). */ function fakeBackend(result: { - exitCode: number | null; - timedOut: boolean; - aborted?: boolean; + exitCode: number | null; + timedOut: boolean; + aborted?: boolean; }): ExecBackend { - return { - ...localExecBackend, - spawn: async () => ({ aborted: false, ...result }), - }; + 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; + return () => backend; } describe("validateArgs", () => { - it("returns validated args for valid input", () => { - const result = validateArgs({ command: "echo hello" }); - expect(result).toEqual({ command: "echo hello", timeout: 120_000 }); - }); - - it("parses custom timeout", () => { - const result = validateArgs({ command: "echo hello", timeout: 5000 }); - expect(result).toEqual({ command: "echo hello", timeout: 5000 }); - }); - - it("floors fractional timeout", () => { - const result = validateArgs({ command: "echo hello", timeout: 5000.7 }); - expect(result).toEqual({ command: "echo hello", timeout: 5000 }); - }); - - it("returns error for null args", () => { - const result = validateArgs(null); - expect(result).toHaveProperty("error"); - }); - - it("returns error for non-object args", () => { - const result = validateArgs("string"); - expect(result).toHaveProperty("error"); - }); - - it("returns error for missing command", () => { - const result = validateArgs({}); - expect(result).toHaveProperty("error"); - }); - - it("rejects missing or empty command", () => { - const empty = validateArgs({ command: "" }); - expect(empty).toHaveProperty("error"); - const whitespace = validateArgs({ command: " " }); - expect(whitespace).toHaveProperty("error"); - const missing = validateArgs({ timeout: 5000 }); - expect(missing).toHaveProperty("error"); - }); - - it("returns error for non-string command", () => { - const result = validateArgs({ command: 123 }); - expect(result).toHaveProperty("error"); - }); - - it("returns error for invalid timeout", () => { - const negative = validateArgs({ command: "echo", timeout: -1 }); - expect(negative).toHaveProperty("error"); - const zero = validateArgs({ command: "echo", timeout: 0 }); - expect(zero).toHaveProperty("error"); - const nan = validateArgs({ command: "echo", timeout: Number.NaN }); - expect(nan).toHaveProperty("error"); - }); + it("returns validated args for valid input", () => { + const result = validateArgs({ command: "echo hello" }); + expect(result).toEqual({ command: "echo hello", timeout: 120_000 }); + }); + + it("parses custom timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("floors fractional timeout", () => { + const result = validateArgs({ command: "echo hello", timeout: 5000.7 }); + expect(result).toEqual({ command: "echo hello", timeout: 5000 }); + }); + + it("returns error for null args", () => { + const result = validateArgs(null); + expect(result).toHaveProperty("error"); + }); + + it("returns error for non-object args", () => { + const result = validateArgs("string"); + expect(result).toHaveProperty("error"); + }); + + it("returns error for missing command", () => { + const result = validateArgs({}); + expect(result).toHaveProperty("error"); + }); + + it("rejects missing or empty command", () => { + const empty = validateArgs({ command: "" }); + expect(empty).toHaveProperty("error"); + const whitespace = validateArgs({ command: " " }); + expect(whitespace).toHaveProperty("error"); + const missing = validateArgs({ timeout: 5000 }); + expect(missing).toHaveProperty("error"); + }); + + it("returns error for non-string command", () => { + const result = validateArgs({ command: 123 }); + expect(result).toHaveProperty("error"); + }); + + it("returns error for invalid timeout", () => { + const negative = validateArgs({ command: "echo", timeout: -1 }); + expect(negative).toHaveProperty("error"); + const zero = validateArgs({ command: "echo", timeout: 0 }); + expect(zero).toHaveProperty("error"); + const nan = validateArgs({ command: "echo", timeout: Number.NaN }); + expect(nan).toHaveProperty("error"); + }); }); describe("truncateOutput", () => { - it("returns output unchanged when under cap", () => { - const output = "short output"; - expect(truncateOutput(output, 100)).toBe("short output"); - }); - - it("returns output unchanged when exactly at cap", () => { - const output = "exact"; - expect(truncateOutput(output, 5)).toBe("exact"); - }); - - it("truncates output beyond the cap and appends a notice", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50); - expect(result).toContain("a".repeat(50)); - expect(result).toContain("[Output truncated: exceeded 50 characters]"); - expect(result.length).toBeLessThan(output.length + 100); - }); + it("returns output unchanged when under cap", () => { + const output = "short output"; + expect(truncateOutput(output, 100)).toBe("short output"); + }); + + it("returns output unchanged when exactly at cap", () => { + const output = "exact"; + expect(truncateOutput(output, 5)).toBe("exact"); + }); + + it("truncates output beyond the cap and appends a notice", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); }); describe("buildResult", () => { - it("maps a zero exit code to a success result", () => { - const result = buildResult({ - exitCode: 0, - timedOut: false, - aborted: false, - output: "all good", - cap: 50_000, - }); - expect(result.content).toBe("all good"); - expect(result.isError).toBeUndefined(); - }); - - it("maps a non-zero exit code to an isError result", () => { - const result = buildResult({ - exitCode: 1, - timedOut: false, - aborted: false, - output: "some error", - cap: 50_000, - }); - expect(result.content).toBe("some error"); - expect(result.isError).toBe(true); - }); - - it("reports a timeout as an isError result", () => { - const result = buildResult({ - exitCode: null, - timedOut: true, - aborted: false, - output: "partial", - cap: 50_000, - }); - expect(result.content).toContain("partial"); - expect(result.content).toContain("[Command timed out]"); - expect(result.isError).toBe(true); - }); - - it("reports abort as an isError result", () => { - const result = buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output: "interrupted", - cap: 50_000, - }); - expect(result.content).toBe("interrupted"); - expect(result.isError).toBe(true); - }); - - it("truncates output in result when over cap", () => { - const output = "x".repeat(60_000); - const result = buildResult({ - exitCode: 0, - timedOut: false, - aborted: false, - output, - cap: 50_000, - }); - expect(result.content).toContain("[Output truncated"); - expect(result.content.length).toBeLessThan(60_000); - }); + it("maps a zero exit code to a success result", () => { + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output: "all good", + cap: 50_000, + }); + expect(result.content).toBe("all good"); + expect(result.isError).toBeUndefined(); + }); + + it("maps a non-zero exit code to an isError result", () => { + const result = buildResult({ + exitCode: 1, + timedOut: false, + aborted: false, + output: "some error", + cap: 50_000, + }); + expect(result.content).toBe("some error"); + expect(result.isError).toBe(true); + }); + + it("reports a timeout as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: true, + aborted: false, + output: "partial", + cap: 50_000, + }); + expect(result.content).toContain("partial"); + expect(result.content).toContain("[Command timed out]"); + expect(result.isError).toBe(true); + }); + + it("reports abort as an isError result", () => { + const result = buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "interrupted", + cap: 50_000, + }); + expect(result.content).toBe("interrupted"); + expect(result.isError).toBe(true); + }); + + it("truncates output in result when over cap", () => { + const output = "x".repeat(60_000); + const result = buildResult({ + exitCode: 0, + timedOut: false, + aborted: false, + output, + cap: 50_000, + }); + expect(result.content).toContain("[Output truncated"); + expect(result.content.length).toBeLessThan(60_000); + }); }); describe("createRunShellTool", () => { - it("has correct name and parameters shape", () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - expect(tool.name).toBe("run_shell"); - expect(tool.parameters.type).toBe("object"); - expect(tool.parameters.required).toEqual(["command"]); - expect(tool.parameters.properties?.command?.type).toBe("string"); - expect(tool.parameters.properties?.timeout?.type).toBe("number"); - }); - - it("concurrencySafe is false", () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - expect(tool.concurrencySafe).toBe(false); - }); - - it("rejects missing or empty command", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - const result = await tool.execute({}, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("Missing or empty"); - }); - - it("maps a zero exit code to a success result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - 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(); - expect(result.content).toContain("hello"); - }); - - it("maps a non-zero exit code to an isError result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - 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); - expect(result.content).toContain("error output"); - }); - - it("reports a timeout as an isError result", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - 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); - expect(result.content).toContain("[Command timed out]"); - }); - - it("truncates output beyond the cap and appends a notice", async () => { - const cap = 100; - const tool = createRunShellTool({ - workdir: "/tmp", - outputCap: cap, - 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"); - expect(result.content.length).toBeLessThan(200); - }); - - it("streams output to ctx.onOutput", async () => { - const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - const tool = createRunShellTool({ - workdir: "/tmp", - 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" }, - stubCtx({ - onOutput: (data, stream) => chunks.push({ data, stream }), - }), - ); - expect(chunks).toEqual([ - { data: "line1\n", stream: "stdout" }, - { data: "err1\n", stream: "stderr" }, - { data: "line2\n", stream: "stdout" }, - ]); - }); - - it("uses ctx.cwd when present over baked workdir", async () => { - let receivedCwd = ""; - const tool = createRunShellTool({ - workdir: "/baked", - 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"); - }); - - it("falls back to baked workdir when ctx.cwd is omitted", async () => { - let receivedCwd = ""; - const tool = createRunShellTool({ - workdir: "/baked", - 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"); - }); - - it("returns error for spawn failure", async () => { - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor({ - ...localExecBackend, - spawn: async () => { - throw new Error("spawn failed"); - }, - }), - }); - const result = await tool.execute({ command: "bad" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("Error spawning command"); - }); - - it("reports abort as isError when signal fires before spawn completes", async () => { - const controller = new AbortController(); - controller.abort(); - const tool = createRunShellTool({ - workdir: "/tmp", - resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), - }); - const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); - expect(result.isError).toBe(true); - }); - - it("passes timeout to spawn", async () => { - let receivedTimeout = 0; - const tool = createRunShellTool({ - workdir: "/tmp", - 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(); - }); + it("has correct name and parameters shape", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + expect(tool.name).toBe("run_shell"); + expect(tool.parameters.type).toBe("object"); + expect(tool.parameters.required).toEqual(["command"]); + expect(tool.parameters.properties?.command?.type).toBe("string"); + expect(tool.parameters.properties?.timeout?.type).toBe("number"); + }); + + it("concurrencySafe is false", () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + expect(tool.concurrencySafe).toBe(false); + }); + + it("rejects missing or empty command", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + const result = await tool.execute({}, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Missing or empty"); + }); + + it("maps a zero exit code to a success result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + 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(); + expect(result.content).toContain("hello"); + }); + + it("maps a non-zero exit code to an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + 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); + expect(result.content).toContain("error output"); + }); + + it("reports a timeout as an isError result", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + 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); + expect(result.content).toContain("[Command timed out]"); + }); + + it("truncates output beyond the cap and appends a notice", async () => { + const cap = 100; + const tool = createRunShellTool({ + workdir: "/tmp", + outputCap: cap, + 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"); + expect(result.content.length).toBeLessThan(200); + }); + + it("streams output to ctx.onOutput", async () => { + const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const tool = createRunShellTool({ + workdir: "/tmp", + 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" }, + stubCtx({ + onOutput: (data, stream) => chunks.push({ data, stream }), + }), + ); + expect(chunks).toEqual([ + { data: "line1\n", stream: "stdout" }, + { data: "err1\n", stream: "stderr" }, + { data: "line2\n", stream: "stdout" }, + ]); + }); + + it("uses ctx.cwd when present over baked workdir", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + 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"); + }); + + it("falls back to baked workdir when ctx.cwd is omitted", async () => { + let receivedCwd = ""; + const tool = createRunShellTool({ + workdir: "/baked", + 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"); + }); + + it("returns error for spawn failure", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async () => { + throw new Error("spawn failed"); + }, + }), + }); + const result = await tool.execute({ command: "bad" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("Error spawning command"); + }); + + it("reports abort as isError when signal fires before spawn completes", async () => { + const controller = new AbortController(); + controller.abort(); + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), + }); + const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); + expect(result.isError).toBe(true); + }); + + it("passes timeout to spawn", async () => { + let receivedTimeout = 0; + const tool = createRunShellTool({ + workdir: "/tmp", + 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 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" }, - stubCtx({ - onOutput: (data) => { - streamed += data; - }, - }), - ); - expect(result.isError).toBeUndefined(); - expect(result.content).toContain("hello-from-shell"); - expect(streamed).toContain("hello-from-shell"); - }); - - it("aborts a real long-running command through the local backend and resolves with aborted", async () => { - const controller = new AbortController(); - 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.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)); - }); + 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" }, + stubCtx({ + onOutput: (data) => { + streamed += data; + }, + }), + ); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("hello-from-shell"); + expect(streamed).toContain("hello-from-shell"); + }); + + it("aborts a real long-running command through the local backend and resolves with aborted", async () => { + const controller = new AbortController(); + 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.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)); + }); }); diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts index dac7fab..e9b676a 100644 --- a/packages/tool-shell/src/shell.ts +++ b/packages/tool-shell/src/shell.ts @@ -6,170 +6,170 @@ const DEFAULT_TIMEOUT = 120_000; const OUTPUT_CAP = 50_000; export interface ValidatedArgs { - readonly command: string; - readonly timeout: number; + readonly command: string; + readonly timeout: number; } export interface SpawnResult { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; } export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record<string, unknown>; - - const rawCommand = obj.command; - if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) { - return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' }; - } - - let timeout = DEFAULT_TIMEOUT; - if (obj.timeout !== undefined) { - const n = Number(obj.timeout); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' }; - } - timeout = Math.floor(n); - } - - return { command: rawCommand, timeout }; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; + + const rawCommand = obj.command; + if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) { + return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' }; + } + + let timeout = DEFAULT_TIMEOUT; + if (obj.timeout !== undefined) { + const n = Number(obj.timeout); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' }; + } + timeout = Math.floor(n); + } + + return { command: rawCommand, timeout }; } export function truncateOutput(output: string, cap: number): string { - if (output.length <= cap) { - return output; - } - const truncated = output.slice(0, cap); - return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; } export function buildResult(params: { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; - readonly output: string; - readonly cap: number; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; + readonly output: string; + readonly cap: number; }): ToolResult { - if (params.aborted) { - return { - content: truncateOutput(params.output, params.cap), - isError: true, - }; - } - if (params.timedOut) { - const content = truncateOutput(params.output, params.cap); - return { - content: `${content}\n\n[Command timed out]`, - isError: true, - }; - } - const exitCode = params.exitCode; - if (exitCode !== null && exitCode !== 0) { - return { - content: truncateOutput(params.output, params.cap), - isError: true, - }; - } - return { - content: truncateOutput(params.output, params.cap), - }; + if (params.aborted) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + if (params.timedOut) { + const content = truncateOutput(params.output, params.cap); + return { + content: `${content}\n\n[Command timed out]`, + isError: true, + }; + } + const exitCode = params.exitCode; + if (exitCode !== null && exitCode !== 0) { + return { + content: truncateOutput(params.output, params.cap), + isError: true, + }; + } + return { + content: truncateOutput(params.output, params.cap), + }; } export function createRunShellTool(deps: { - readonly workdir: string; - readonly resolveBackend: ExecBackendResolver; - readonly outputCap?: number; + readonly workdir: string; + readonly resolveBackend: ExecBackendResolver; + readonly outputCap?: number; }): ToolContract { - const workdir = resolve(deps.workdir); - const cap = deps.outputCap ?? OUTPUT_CAP; - - return { - name: "run_shell", - description: - "Execute a shell command and return its output. " + - "Use for running CLI tools, scripts, or system commands.", - parameters: { - type: "object", - properties: { - command: { - type: "string", - description: "The shell command to execute.", - }, - timeout: { - type: "number", - description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`, - default: DEFAULT_TIMEOUT, - }, - }, - required: ["command"], - }, - concurrencySafe: false, - async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } - - const { command, timeout } = validated; - const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir; - - if (ctx.signal.aborted) { - return buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output: "", - cap, - }); - } - - let output = ""; - const appendOutput = (data: string, _stream: "stdout" | "stderr") => { - output += data; - }; - - const backend = deps.resolveBackend(ctx.computerId); - - let spawnResult: SpawnResult; - - try { - spawnResult = await backend.spawn({ - command, - cwd: effectiveCwd, - signal: ctx.signal, - timeout, - onOutput: (data, stream) => { - ctx.onOutput(data, stream); - appendOutput(data, stream); - }, - }); - } catch (err: unknown) { - if (ctx.signal.aborted) { - return buildResult({ - exitCode: null, - timedOut: false, - aborted: true, - output, - cap, - }); - } - return { - content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - - return buildResult({ - exitCode: spawnResult.exitCode, - timedOut: spawnResult.timedOut, - aborted: spawnResult.aborted, - output, - cap, - }); - }, - }; + const workdir = resolve(deps.workdir); + const cap = deps.outputCap ?? OUTPUT_CAP; + + return { + name: "run_shell", + description: + "Execute a shell command and return its output. " + + "Use for running CLI tools, scripts, or system commands.", + parameters: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to execute.", + }, + timeout: { + type: "number", + description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`, + default: DEFAULT_TIMEOUT, + }, + }, + required: ["command"], + }, + concurrencySafe: false, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { command, timeout } = validated; + const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir; + + if (ctx.signal.aborted) { + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output: "", + cap, + }); + } + + let output = ""; + const appendOutput = (data: string, _stream: "stdout" | "stderr") => { + output += data; + }; + + const backend = deps.resolveBackend(ctx.computerId); + + let spawnResult: SpawnResult; + + try { + spawnResult = await backend.spawn({ + command, + cwd: effectiveCwd, + signal: ctx.signal, + timeout, + onOutput: (data, stream) => { + ctx.onOutput(data, stream); + appendOutput(data, stream); + }, + }); + } catch (err: unknown) { + if (ctx.signal.aborted) { + return buildResult({ + exitCode: null, + timedOut: false, + aborted: true, + output, + cap, + }); + } + return { + content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + return buildResult({ + exitCode: spawnResult.exitCode, + timedOut: spawnResult.timedOut, + aborted: spawnResult.aborted, + output, + cap, + }); + }, + }; } |
