/** * KHAELOR * File: tests/tools/bash.test.ts * Description: Unit tests for the bash tool — output format, exit codes, timeout redirect, truncation. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { existsSync, mkdirSync, rmSync } from "node:fs"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createBashTool } from "../../src/tools/index.js"; import type { TestHarness } from "./helpers.js"; import { makeHarness } from "./helpers.js"; let h: TestHarness; const bash = createBashTool(); beforeEach(() => { h = makeHarness(); }); afterEach(async () => { await h.processes.stopAll(); rmSync(h.dir, { recursive: true, force: true }); }); describe("bash tool", () => { it("returns command output with the $ header and exit-code footer", async () => { const result = await bash.execute({ command: "echo hello" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.content).toContain("$ echo hello"); expect(result.content).toContain("hello"); expect(result.content).toMatch(/\[exit code 0 · \d+\.\ds · cwd /); expect(result.metadata?.exitCode).toBe(0); }); it("treats non-zero exits as observations, not errors", async () => { const result = await bash.execute({ command: "echo boom >&2; exit 3" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.content).toContain("boom"); expect(result.content).toContain("[exit code 3 ·"); expect(result.metadata?.exitCode).toBe(3); }); it("honors the workdir parameter", async () => { const sub = path.join(h.dir, "subdir"); mkdirSync(sub); const result = await bash.execute({ command: "pwd", workdir: "subdir" }, h.ctx); expect(result.content).toContain("subdir"); expect(result.content).toContain(`cwd ${sub}`); }); it("redirects a still-running command to the process manager at the ceiling", async () => { const result = await bash.execute( { command: "sleep 5; echo done", timeout_ms: 300 }, h.ctx, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("Command still running after 0s — moved to background as process p1."); expect(result.content).toContain("Output so far:"); expect(result.content).toContain('Use process {"action":"read","id":"p1"} to see new output'); expect(result.content).toContain('{"action":"stop","id":"p1"} to stop it.'); expect(result.metadata?.processId).toBe("p1"); // The command was NOT killed — it is alive under the process manager. const managed = h.processes.list().find((p) => p.id === "p1"); expect(managed).toBeDefined(); expect(managed?.status).toBe("running"); await h.processes.stop("p1"); }); it("truncates long output middle-out with a spill marker", async () => { const result = await bash.execute( { command: "i=1; while [ $i -le 600 ]; do echo line-$i; i=$((i+1)); done" }, h.ctx, ); expect(result.isError).toBeUndefined(); expect(result.content).toContain("line-1"); expect(result.content).toContain("line-600"); expect(result.content).toMatch(/\[\.\.\. [\d,]+ lines omitted \(\d+ KB\)\. Full output: .* — read or grep that file for the rest\.\]/); const info = result.metadata?.truncation; expect(info).toBeDefined(); expect(info!.shownHeadLines).toBeLessThanOrEqual(250); expect(info!.spillPath).toBeDefined(); expect(existsSync(info!.spillPath!)).toBe(true); }); it("interleaves stdout and stderr", async () => { const result = await bash.execute({ command: "echo out; echo err >&2" }, h.ctx); expect(result.content).toContain("out"); expect(result.content).toContain("err"); }); it("cancels via the abort signal with the synthetic cancelled result", async () => { const promise = bash.execute({ command: "sleep 10", timeout_ms: 30_000 }, h.ctx); setTimeout(() => h.abort.abort(), 100); const result = await promise; expect(result.isError).toBe(true); expect(result.content).toBe("[Tool execution cancelled by user]"); }); it("clamps timeout_ms to the 300s ceiling without failing", async () => { const result = await bash.execute({ command: "echo fast", timeout_ms: 999_999 }, h.ctx); expect(result.content).toContain("fast"); expect(result.metadata?.exitCode).toBe(0); }); });