// Ported from packages/agent/test/harness/tools.test.ts and adapted to ToolRegistration: tools take the environment // from `api.env`, stream through `api.output()`, and report notices as diagnostics instead of content text. import { appendFileSync, mkdirSync, rmSync } from "node:fs"; import { symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Context, JsonValue } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context"; import type { ToolDiagnostic, ToolExecutionApi, ToolExecutionResult, ToolRegistration, } from "@earendil-works/pi-durable"; import { applyPatch } from "diff"; import { afterAll, describe, expect, it } from "vitest"; import { type ExecutionEnv, ExecutionError, err, type FileError, getOrThrow, type Result, type ShellExecOptions, type ShellExecResult, type ShellOutputSkip, type ShellOutputWindow, } from "../src/env/index.ts"; import { NodeExecutionEnv } from "../src/env/node.ts"; import { withFileMutationQueue } from "../src/tools/file-mutation-queue.ts"; import { detectSupportedImageMimeType } from "../src/tools/image.ts"; import { createBashTool, createEditTool, createPowerShellTool, createReadTool, createWriteTool, } from "../src/tools/index.ts"; import { DEFAULT_MAX_LINES } from "../src/truncate.ts"; const tempDirs: string[] = []; afterAll(() => { for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); }); function createTempDir(): string { const dir = join(tmpdir(), `pi-durable-tools-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); tempDirs.push(dir); return dir; } function createEnv(): NodeExecutionEnv { return new NodeExecutionEnv({ cwd: createTempDir() }); } /** A minimal execution API: the environment, collected output and diagnostics, and nothing durable. */ function fakeApi(env: ExecutionEnv | undefined): { api: ToolExecutionApi; output: string[]; diagnostics: ToolDiagnostic[]; } { const output: string[] = []; const diagnostics: ToolDiagnostic[] = []; const api = { taskId: 1, conversationId: 1, callId: "call", env, output: (chunk: string | Uint8Array) => output.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)), retainedOutput: () => ({ text: output.join(""), truncated: false }), diagnostic: (diagnostic: ToolDiagnostic) => diagnostics.push(diagnostic), details: async () => {}, // No model: `read` treats it as one that sees images. agent: async () => ({}), } as unknown as ToolExecutionApi; return { api, output, diagnostics }; } async function run( tool: ToolRegistration, args: JsonValue, env: ExecutionEnv | undefined, context: Context = BACKGROUND_CONTEXT, ): Promise { const { api, output, diagnostics } = fakeApi(env); return { ...(await tool.execute(args, api, context)), streamed: output, reported: diagnostics }; } /** Run a tool expected to throw; returns the error with what it streamed and reported first. */ async function runFailing( tool: ToolRegistration, args: JsonValue, env: ExecutionEnv, ): Promise<{ error: Error; streamed: string[]; reported: ToolDiagnostic[] }> { const { api, output, diagnostics } = fakeApi(env); try { await tool.execute(args, api, BACKGROUND_CONTEXT); } catch (error) { return { error: error as Error, streamed: output, reported: diagnostics }; } throw new Error("Expected the tool to throw"); } function textOutput(result: ToolExecutionResult): string { return (result.output ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"); } function diagnosticText(result: ToolExecutionResult): string { return (result.diagnostics ?? []).map((diagnostic) => diagnostic.message).join("\n"); } function deferred(): { promise: Promise; resolve: () => void } { let resolve = () => {}; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } class SlowReadExecutionEnv extends NodeExecutionEnv { override async readTextFile(path: string, context: Context): Promise> { await delay(20); return super.readTextFile(path, context); } } class BlockingWriteExecutionEnv extends NodeExecutionEnv { readonly firstWriteStarted = deferred(); readonly finishFirstWrite = deferred(); secondWriteStarted = false; override async writeFile( path: string, content: string | Uint8Array, context: Context, ): Promise> { if (content === "first\n") { this.firstWriteStarted.resolve(); await this.finishFirstWrite.promise; } else if (content === "second\n") { this.secondWriteStarted = true; } return super.writeFile(path, content, context); } } class BlockingEditExecutionEnv extends NodeExecutionEnv { readonly firstEditWriteStarted = deferred(); readonly finishFirstEditWrite = deferred(); firstEditWriteSettled = false; secondEditWriteStarted = false; override async writeFile( path: string, content: string | Uint8Array, context: Context, ): Promise> { if (content === "ALPHA\nbeta\n") { this.firstEditWriteStarted.resolve(); await this.finishFirstEditWrite.promise; const result = await super.writeFile(path, content, BACKGROUND_CONTEXT); this.firstEditWriteSettled = true; return result; } if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") { this.secondEditWriteStarted = true; } return super.writeFile(path, content, context); } } const TRUNCATED_OUTPUT_LINES = DEFAULT_MAX_LINES + 1; class TimeoutOutputExecutionEnv extends NodeExecutionEnv { override async exec( _command: string | readonly string[], options: ShellExecOptions | undefined, context: Context, ): Promise> { const output = `${Array.from({ length: TRUNCATED_OUTPUT_LINES }, (_, index) => `line-${index + 1}`).join("\n")}\n`; const spillPath = getOrThrow(await this.createTempFile({ prefix: "timeout-", suffix: ".log" }, context)); getOrThrow(await this.writeFile(spillPath, output, context)); options?.onOutput?.(output, context, { stream: "stdout" }); const error = new ExecutionError("timeout", `timeout:${options?.timeout}`); error.spillPath = spillPath; return err(error); } } describe("durable tools", () => { it("fail with an ordinary error when no environment is configured", async () => { await expect(run(createReadTool(), { path: "x" }, undefined)).rejects.toThrow("No execution environment"); }); describe("read", () => { it.each(["GIF87a", "GIF89a"])("detects the complete %s signature", (signature) => { expect(detectSupportedImageMimeType(Buffer.from(signature, "ascii"))).toBe("image/gif"); }); it("reads text with offsets and limits and reports continuation as a diagnostic", async () => { const env = createEnv(); getOrThrow( await env.writeFile( "test.txt", Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join("\n"), BACKGROUND_CONTEXT, ), ); const result = await run(createReadTool(), { path: "test.txt", offset: 41, limit: 20 }, env); const output = textOutput(result); expect(output).not.toContain("Line 40"); expect(output).toContain("Line 41"); expect(output).toContain("Line 60"); expect(output).not.toContain("Line 61"); expect(output).not.toContain("more lines"); expect(diagnosticText(result)).toBe("40 more lines in file. Use offset=61 to continue."); }); it("truncates large text by line count", async () => { const env = createEnv(); getOrThrow( await env.writeFile( "large.txt", Array.from({ length: 2500 }, (_, index) => `Line ${index + 1}`).join("\n"), BACKGROUND_CONTEXT, ), ); const result = await run(createReadTool(), { path: "large.txt" }, env); expect(diagnosticText(result)).toBe("Showing lines 1-2000 of 2500. Use offset=2001 to continue."); expect(result.diagnostics?.[0]?.code).toBe("truncated"); expect((result.details as { truncation?: unknown } | undefined)?.truncation).toMatchObject({ truncated: true, truncatedBy: "lines", totalLines: 2500, outputLines: 2000, }); }); it("does not count a trailing newline as an extra line at the truncation limit", async () => { const env = createEnv(); getOrThrow( await env.writeFile( "exact.txt", `${Array.from({ length: 2000 }, () => "x").join("\n")}\n`, BACKGROUND_CONTEXT, ), ); const result = await run(createReadTool(), { path: "exact.txt" }, env); expect(result.details).toBeUndefined(); expect(result.diagnostics).toEqual([]); }); it("shows the start of a line longer than the byte limit", async () => { const env = createEnv(); getOrThrow(await env.writeFile("long.txt", `${"é".repeat(40_000)}\nnext\n`, BACKGROUND_CONTEXT)); const result = await run(createReadTool(), { path: "long.txt" }, env); const text = textOutput(result); // Two-byte characters: the cut lands on a character boundary at or below the limit. expect(text).toBe("é".repeat(25_600)); expect(diagnosticText(result)).toBe( "Line 1 is 78.1KB, exceeds the 50.0KB limit; showing its first 50.0KB. Use bash: sed -n '1p' long.txt | tail -c +51201", ); expect((result.details as { truncation: object }).truncation).toMatchObject({ truncated: true, firstLineExceedsLimit: true, outputBytes: 51_200, outputLines: 1, }); expect(result.details).not.toHaveProperty("truncation.content"); }); it("rejects offsets beyond the file", async () => { const env = createEnv(); getOrThrow(await env.writeFile("short.txt", "one\ntwo\nthree", BACKGROUND_CONTEXT)); await expect(run(createReadTool(), { path: "short.txt", offset: 100 }, env)).rejects.toThrow( "Offset 100 is beyond end of file (3 lines total)", ); }); it("reads a log that grows while it is read", async () => { const env = createEnv(); getOrThrow(await env.writeFile("app.log", "one\ntwo\n", BACKGROUND_CONTEXT)); const logPath = join(env.cwd, "app.log"); // Every read appends, as a busy writer would; the scanned lines stay as they were. const growing = Object.create(env) as NodeExecutionEnv; growing.openBinaryReader = async (path, options, context) => { const opened = await env.openBinaryReader(path, options, context); if (!opened.ok) return opened; const reader = opened.value; const read = reader.read.bind(reader); reader.read = async (position, length, readContext) => { appendFileSync(logPath, "more\n"); return read(position, length, readContext); }; return opened; }; const result = await run(createReadTool(), { path: "app.log", limit: 2 }, growing); expect(textOutput(result)).toBe("one\ntwo"); }); it("returns images by content as one image block", async () => { const env = createEnv(); const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=="; getOrThrow(await env.writeFile("image.txt", Uint8Array.from(Buffer.from(data, "base64")), BACKGROUND_CONTEXT)); const result = await run(createReadTool(), { path: "image.txt" }, env); expect(result.output).toEqual([{ type: "image", data, mimeType: "image/png" }]); expect(result.isError).toBeUndefined(); expect(diagnosticText(result)).toBe("Read image file [image/png]."); }); }); describe("write", () => { it("writes files and creates parent directories", async () => { const env = createEnv(); const result = await run(createWriteTool(), { path: "nested/dir/file.txt", content: "hello" }, env); expect(textOutput(result)).toBe("Successfully wrote to nested/dir/file.txt"); expect(getOrThrow(await env.readTextFile("nested/dir/file.txt", BACKGROUND_CONTEXT))).toBe("hello"); }); it("keeps the mutation queue locked until an aborted write settles", async () => { const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() }); const tool = createWriteTool(); const controller = new AbortController(); const firstWrite = run( tool, { path: "file.txt", content: "first\n" }, env, withAbortSignal(controller.signal, BACKGROUND_CONTEXT), ); await env.firstWriteStarted.promise; controller.abort(); const secondWrite = run(tool, { path: "file.txt", content: "second\n" }, env); await delay(20); expect(env.secondWriteStarted).toBe(false); env.finishFirstWrite.resolve(); await expect(firstWrite).rejects.toThrow(); await secondWrite; expect(getOrThrow(await env.readTextFile("file.txt", BACKGROUND_CONTEXT))).toBe("second\n"); }); }); describe("edit", () => { it("applies disjoint edits and returns both diff formats", async () => { const env = createEnv(); const original = "alpha\nbeta\ngamma\ndelta\n"; getOrThrow(await env.writeFile("edit.txt", original, BACKGROUND_CONTEXT)); const result = await run( createEditTool(), { path: "edit.txt", edits: [ { oldText: "alpha\n", newText: "ALPHA\n" }, { oldText: "gamma\n", newText: "GAMMA\n" }, ], }, env, ); const details = result.details as { diff: string; patch: string }; expect(textOutput(result)).toBe("Successfully replaced 2 block(s) in edit.txt."); expect(details.diff).toContain("ALPHA"); expect(details.diff).toContain("GAMMA"); expect(applyPatch(original, details.patch)).toBe("ALPHA\nbeta\nGAMMA\ndelta\n"); expect(getOrThrow(await env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nbeta\nGAMMA\ndelta\n"); }); it("repairs edits sent as a JSON string, a single object, or top-level oldText/newText without mutating them", () => { const prepare = createEditTool().prepareArguments!; const edit = { oldText: "a", newText: "b" }; const asString = { path: "f", edits: JSON.stringify([edit]) }; expect(prepare(asString)).toEqual({ path: "f", edits: [edit] }); expect(asString.edits).toBe(JSON.stringify([edit])); expect(prepare({ path: "f", edits: JSON.stringify(edit) })).toEqual({ path: "f", edits: [edit] }); expect(prepare({ path: "f", edits: edit })).toEqual({ path: "f", edits: [edit] }); expect(prepare({ path: "f", edits: [edit], oldText: "c", newText: "d" })).toEqual({ path: "f", edits: [edit, { oldText: "c", newText: "d" }], }); expect(prepare({ path: "f", edits: "not json" })).toEqual({ path: "f", edits: "not json" }); }); it("matches all edits against the original and rejects overlaps", async () => { const env = createEnv(); getOrThrow(await env.writeFile("edit.txt", "one\ntwo\nthree\n", BACKGROUND_CONTEXT)); await expect( run( createEditTool(), { path: "edit.txt", edits: [ { oldText: "one\ntwo\n", newText: "ONE\nTWO\n" }, { oldText: "two\nthree\n", newText: "TWO\nTHREE\n" }, ], }, env, ), ).rejects.toThrow(/overlap/); expect(getOrThrow(await env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe("one\ntwo\nthree\n"); }); it("rejects missing and duplicate target text", async () => { const env = createEnv(); getOrThrow(await env.writeFile("edit.txt", "foo foo foo", BACKGROUND_CONTEXT)); const tool = createEditTool(); await expect( run(tool, { path: "edit.txt", edits: [{ oldText: "bar", newText: "baz" }] }, env), ).rejects.toThrow(/Could not find the exact text/); await expect( run(tool, { path: "edit.txt", edits: [{ oldText: "foo", newText: "bar" }] }, env), ).rejects.toThrow(/Found 3 occurrences/); }); it("keeps the mutation queue locked until an aborted edit write settles", async () => { const env = new BlockingEditExecutionEnv({ cwd: createTempDir() }); getOrThrow(await env.writeFile("file.txt", "alpha\nbeta\n", BACKGROUND_CONTEXT)); const tool = createEditTool(); const controller = new AbortController(); const firstEdit = run( tool, { path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, env, withAbortSignal(controller.signal, BACKGROUND_CONTEXT), ); await env.firstEditWriteStarted.promise; controller.abort(); const secondEdit = run(tool, { path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, env); await delay(20); expect(env.secondEditWriteStarted).toBe(false); env.finishFirstEditWrite.resolve(); await expect(firstEdit).rejects.toThrow("Operation aborted"); await secondEdit; expect(env.firstEditWriteSettled).toBe(true); expect(getOrThrow(await env.readTextFile("file.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\n"); }); it("serializes concurrent edits through canonical and symlink paths", async () => { const env = new SlowReadExecutionEnv({ cwd: createTempDir() }); getOrThrow(await env.writeFile("target.txt", "alpha\nbeta\ngamma\n", BACKGROUND_CONTEXT)); await symlink("target.txt", `${env.cwd}/link.txt`); const tool = createEditTool(); await Promise.all([ run(tool, { path: "target.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, env), run(tool, { path: "link.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, env), ]); expect(getOrThrow(await env.readTextFile("target.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\ngamma\n"); }); it("serializes edits of one file across environment objects of one file system", async () => { const dir = createTempDir(); const first = new SlowReadExecutionEnv({ cwd: dir }); const second = new SlowReadExecutionEnv({ cwd: dir }); getOrThrow(await first.writeFile("file.txt", "alpha\nbeta\n", BACKGROUND_CONTEXT)); const tool = createEditTool(); await Promise.all([ run(tool, { path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, first), run(tool, { path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, second), ]); expect(getOrThrow(await first.readTextFile("file.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\n"); }); it("serializes a new file created through a symlinked directory with its canonical path", async () => { const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() }); mkdirSync(`${env.cwd}/real`); await symlink(`${env.cwd}/real`, `${env.cwd}/link`); const tool = createWriteTool(); const first = run(tool, { path: "link/new.txt", content: "first\n" }, env); await env.firstWriteStarted.promise; const second = run(tool, { path: "real/new.txt", content: "second\n" }, env); await delay(20); expect(env.secondWriteStarted).toBe(false); env.finishFirstWrite.resolve(); await Promise.all([first, second]); expect(getOrThrow(await env.readTextFile("real/new.txt", BACKGROUND_CONTEXT))).toBe("second\n"); }); it.skipIf(process.platform === "win32")( "keys a missing file whose name contains a backslash like the created file", async () => { const env = createEnv(); const created = deferred(); const release = deferred(); const first = withFileMutationQueue( env, "a\\b.txt", async () => { getOrThrow(await env.writeFile("a\\b.txt", "first\n", BACKGROUND_CONTEXT)); created.resolve(); await release.promise; }, BACKGROUND_CONTEXT, ); await created.promise; let entered = false; const second = withFileMutationQueue( env, "a\\b.txt", async () => { entered = true; }, BACKGROUND_CONTEXT, ); await delay(20); expect(entered).toBe(false); release.resolve(); await Promise.all([first, second]); expect(entered).toBe(true); }, ); it("does not serialize the same path on different file systems", async () => { class OtherFileSystem extends BlockingWriteExecutionEnv { override readonly id = "other"; } const dir = createTempDir(); const local = new BlockingWriteExecutionEnv({ cwd: dir }); const other = new OtherFileSystem({ cwd: dir }); const tool = createWriteTool(); const blocked = run(tool, { path: "file.txt", content: "first\n" }, local); await local.firstWriteStarted.promise; await run(tool, { path: "file.txt", content: "second\n" }, other); expect(other.secondWriteStarted).toBe(true); local.finishFirstWrite.resolve(); await blocked; }); it("edits regular files through symlinks", async () => { const env = createEnv(); getOrThrow(await env.writeFile("target.txt", "before\n", BACKGROUND_CONTEXT)); await symlink("target.txt", `${env.cwd}/link.txt`); await run(createEditTool(), { path: "link.txt", edits: [{ oldText: "before", newText: "after" }] }, env); expect(getOrThrow(await env.readTextFile("target.txt", BACKGROUND_CONTEXT))).toBe("after\n"); }); it("preserves BOM and CRLF line endings", async () => { const env = createEnv(); getOrThrow(await env.writeFile("edit.txt", "\uFEFFone\r\ntwo\r\n", BACKGROUND_CONTEXT)); await run(createEditTool(), { path: "edit.txt", edits: [{ oldText: "two", newText: "TWO" }] }, env); expect(getOrThrow(await env.readTextFile("edit.txt", BACKGROUND_CONTEXT))).toBe("\uFEFFone\r\nTWO\r\n"); }); }); describe("powershell", () => { /** An environment where only the listed programs exist; it records each command and prints `output`. */ function programsEnv(installed: readonly string[], output: string, exitCode = 0) { const commands: (string | readonly string[])[] = []; class ProgramsEnv extends NodeExecutionEnv { override async exec( command: string | readonly string[], options: ShellExecOptions | undefined, context: Context, ): Promise> { commands.push(command); if (typeof command === "string" || !installed.includes(command[0]!)) { return err(new ExecutionError("spawn_error", `spawn ${command[0]} ENOENT`)); } options?.onOutput?.(output, context, { stream: "stdout" }); return { ok: true, value: { exitCode } }; } } return { env: new ProgramsEnv({ cwd: createTempDir() }), commands }; } it("runs the command with pwsh as one argument, forcing UTF-8 output", async () => { const { env, commands } = programsEnv(["pwsh"], "héllo\n"); const result = await run(createPowerShellTool(), { command: "Write-Output 'héllo'" }, env); expect(result.streamed.join("")).toBe("héllo\n"); expect(commands).toEqual([ [ "pwsh", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\nWrite-Output 'héllo'", ], ]); }); it("falls back to Windows PowerShell and reports the last start failure", async () => { const windowsOnly = programsEnv(["powershell"], "ok"); const result = await run( createPowerShellTool({ commandPrefix: "$x = 1" }), { command: "$x" }, windowsOnly.env, ); expect(result.streamed.join("")).toBe("ok"); expect(windowsOnly.commands.map((command) => command[0])).toEqual(["pwsh", "powershell"]); expect(String(windowsOnly.commands[1]?.at(-1))).toMatch(/\n\$x = 1\n\$x$/); const none = programsEnv([], ""); const failed = await runFailing(createPowerShellTool(), { command: "1" }, none.env); expect(failed.error.message).toBe("spawn powershell ENOENT"); }); it("answers a nonzero exit with an error result carrying the exit code", async () => { const { env } = programsEnv(["pwsh"], "partial", 3); const result = await run(createPowerShellTool(), { command: "exit 3" }, env); expect(result).toMatchObject({ isError: true, structuredOutput: { output: "partial", truncated: false, exitCode: 3 }, diagnostics: [{ severity: "error", code: "exit_code", message: "Command exited with code 3" }], }); expect(result.streamed.join("")).toBe("partial"); }); it.runIf(process.platform === "win32")("runs real PowerShell with UTF-8 output", async () => { const result = await run( createPowerShellTool(), { command: "Write-Output ('h' + [char]0xe9 + 'llo'); exit 0" }, createEnv(), ); expect(result.streamed.join("").trim()).toBe("héllo"); }); }); describe("bash", () => { it("passes the retained window to the environment and forwards what it skipped", async () => { const window: ShellOutputWindow = { maxBytes: 4, maxLines: 1, minIntervalMs: 100, bytesPerSecond: 1024 }; const skipped: ShellOutputSkip = { bytes: 6, newlines: 2, endsWithNewline: true }; let received: ShellOutputWindow | undefined; class SkippingEnv extends NodeExecutionEnv { override async exec( _command: string | readonly string[], options: ShellExecOptions | undefined, context: Context, ): Promise> { received = options?.window; options?.onOutput?.("tail\n", context, { stream: "stdout", skipped }); return { ok: true, value: { exitCode: 0 } }; } } const calls: [string, ShellOutputSkip | undefined][] = []; const api = { ...fakeApi(new SkippingEnv({ cwd: createTempDir() })).api, outputWindow: window, output: (chunk: string | Uint8Array, skip?: ShellOutputSkip) => calls.push([String(chunk), skip]), } as ToolExecutionApi; await createBashTool().execute({ command: "anything" }, api, BACKGROUND_CONTEXT); expect(received).toEqual(window); expect(calls).toEqual([["tail\n", skipped]]); }); it("streams combined stdout and stderr, returns no output of its own, and repeats it with the exit code", async () => { const result = await run(createBashTool(), { command: "printf out; printf err >&2" }, createEnv()); expect(result.streamed.join("")).toContain("out"); expect(result.streamed.join("")).toContain("err"); expect(result.output).toBeUndefined(); expect(result.structuredOutput).toEqual({ output: result.streamed.join(""), truncated: false, exitCode: 0 }); }); it("answers a nonzero exit with an error result, and throws on a timeout, after streaming the output", async () => { const env = createEnv(); const tool = createBashTool(); const failed = await run(tool, { command: "printf failed; exit 7" }, env); expect(failed).toMatchObject({ isError: true, structuredOutput: { output: "failed", truncated: false, exitCode: 7 }, diagnostics: [{ code: "exit_code", message: "Command exited with code 7" }], }); expect(failed.streamed.join("")).toBe("failed"); const slow = await runFailing(tool, { command: "sleep 2", timeout: 0.01 }, env); expect(slow.error.message).toBe("Command timed out after 0.01 seconds"); }); it("reports the spill of a command that times out", async () => { const env = new TimeoutOutputExecutionEnv({ cwd: createTempDir() }); const failed = await runFailing( createBashTool(), { command: "emit-output-then-time-out", timeout: 0.05 }, env, ); expect(failed.error.message).toBe("Command timed out after 0.05 seconds"); const fullOutputPath = failed.reported[0]?.message.match(/^Full output: (.+)$/)?.[1]; expect(fullOutputPath).toBeDefined(); const fullOutput = getOrThrow(await env.readTextFile(fullOutputPath!, BACKGROUND_CONTEXT)); expect(fullOutput).toContain("line-1\nline-2"); expect(fullOutput).toContain(`line-${DEFAULT_MAX_LINES}\nline-${TRUNCATED_OUTPUT_LINES}`); }); it("prepares command, cwd, and an explicit environment with the call's api", async () => { const env = new NodeExecutionEnv({ cwd: createTempDir(), shellEnv: { PI_BASH_PREPARE_INHERITED: "inherited" }, }); getOrThrow(await env.createDir("workspace", undefined, BACKGROUND_CONTEXT)); const workspace = `${env.cwd}/workspace`; const controller = new AbortController(); let receivedEnv: ExecutionEnv | undefined; let receivedSignal: AbortSignal | undefined; const tool = createBashTool({ commandPrefix: "prefix=ready", prepare: async (execution, api, callContext) => { receivedEnv = api.env; receivedSignal = callContext.abortSignal; execution.cwd = workspace; execution.env = { PI_BASH_PREPARE_EXPLICIT: "explicit" }; execution.inheritEnv = false; execution.command += `\n: > prepared-cwd\nprintf '%s:%s:%s' "$prefix" "\${PI_BASH_PREPARE_INHERITED-}" "$PI_BASH_PREPARE_EXPLICIT"`; // Git Bash on Windows reports $PWD as an MSYS path, so only POSIX compares it. if (process.platform !== "win32") execution.command += `\nprintf ':%s' "$PWD"`; }, }); const result = await run(tool, { command: ":" }, env, withAbortSignal(controller.signal, BACKGROUND_CONTEXT)); expect(receivedEnv).toBe(env); expect(receivedSignal).toBe(controller.signal); const pwd = process.platform === "win32" ? "" : `:${getOrThrow(await env.canonicalPath(workspace, BACKGROUND_CONTEXT))}`; expect(result.streamed.join("")).toBe(`ready::explicit${pwd}`); expect(getOrThrow(await env.exists(`${workspace}/prepared-cwd`, BACKGROUND_CONTEXT))).toBe(true); }); it("supports command prefixes", async () => { const result = await run( createBashTool({ commandPrefix: "value=hello" }), { command: "printf $value" }, createEnv(), ); expect(result.streamed.join("")).toBe("hello"); }); it("streams every byte and spills complete output beyond the default limits", async () => { const env = createEnv(); const result = await run( createBashTool(), { command: "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done" }, env, ); const expected = Array.from({ length: 3000 }, (_, index) => `line-${index + 1}\n`).join(""); expect(result.streamed.join("")).toBe(expected); const fullOutputPath = result.reported[0]?.message.match(/^Full output: (.+)$/)?.[1]; expect(fullOutputPath).toBeDefined(); expect(getOrThrow(await env.readTextFile(fullOutputPath!, BACKGROUND_CONTEXT))).toBe(expected); }); it("does not spill output within the limits", async () => { const result = await run(createBashTool(), { command: "printf small" }, createEnv()); expect(result.reported).toEqual([]); }); }); });