# Conflicts: # ROADMAP.md # TODO.md # src/cli.tsx # src/config.ts # src/mcp.ts # src/permission.ts # src/prompt.ts # src/session.ts # src/snapshot.ts # src/subagent.ts # src/tools-extra.ts # src/tools.ts # src/ui/App.tsx # test/mcp.test.ts # test/prune.test.ts # test/session.test.ts # test/tools.test.ts
783 lines
31 KiB
TypeScript
783 lines
31 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import type { z } from 'zod';
|
|
import {
|
|
applyPatchTool,
|
|
bashTool,
|
|
bashStatusTool,
|
|
bashStopTool,
|
|
deleteFileTool,
|
|
editFileTool,
|
|
globTool,
|
|
grepTool,
|
|
interruptBash,
|
|
jail,
|
|
listDirTool,
|
|
moveFileTool,
|
|
multiEditTool,
|
|
MUTATING_TOOLS,
|
|
onBashOutput,
|
|
parsePatch,
|
|
readFileTool,
|
|
readManyFilesTool,
|
|
tools,
|
|
toolSetOf,
|
|
TOOL_SET_NAMES,
|
|
TOOL_SETS,
|
|
writeFileTool,
|
|
shutdownBackgrounds,
|
|
startBackground,
|
|
stopBackground,
|
|
statusBackground,
|
|
} from '../src/tools';
|
|
|
|
let dir: string;
|
|
let origCwd: string;
|
|
|
|
/** Tools resolve paths against process.cwd(), so each test runs inside a temp workspace. */
|
|
beforeEach(() => {
|
|
origCwd = process.cwd();
|
|
dir = mkdtempSync(join(tmpdir(), 'shiro-'));
|
|
process.chdir(dir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
onBashOutput(undefined);
|
|
process.chdir(origCwd);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const run = <T>(t: { execute?: (input: T, opts: any) => unknown }, input: T, opts: any = { toolCallId: 't1', messages: [] }) =>
|
|
Promise.resolve(t.execute!(input, opts)) as Promise<string>;
|
|
|
|
test('jail rejects traversal and absolute escapes', () => {
|
|
expect(() => jail('../secret')).toThrow(/escapes workspace/);
|
|
expect(() => jail('a/../../secret')).toThrow(/escapes workspace/);
|
|
expect(jail('a/b.ts')).toBe(join(process.cwd(), 'a', 'b.ts'));
|
|
});
|
|
|
|
test('read_file numbers lines and honours offset/limit', async () => {
|
|
await Bun.write(join(dir, 'a.txt'), 'one\ntwo\nthree\n');
|
|
expect(await run(readFileTool, { path: 'a.txt' })).toBe('1: one\n2: two\n3: three\n4: ');
|
|
expect(await run(readFileTool, { path: 'a.txt', offset: 2, limit: 1 })).toBe('2: two');
|
|
});
|
|
|
|
test('read_file on missing path throws', async () => {
|
|
expect(run(readFileTool, { path: 'nope.txt' })).rejects.toThrow(/No such file/);
|
|
});
|
|
|
|
test('read_file refuses a binary file instead of dumping mojibake', async () => {
|
|
await Bun.write(join(dir, 'blob.bin'), new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01, 0x02, 0x03]));
|
|
expect(run(readFileTool, { path: 'blob.bin' })).rejects.toThrow(/binary file/);
|
|
});
|
|
|
|
test('read_file still accepts UTF-8 with high codepoints', async () => {
|
|
await Bun.write(join(dir, 'u.txt'), 'hello -> world\n');
|
|
expect(await run(readFileTool, { path: 'u.txt' })).toContain('hello -> world');
|
|
});
|
|
|
|
test('read_many_files returns one labelled block per file', async () => {
|
|
await Bun.write(join(dir, 'a.ts'), 'const a = 1;\n');
|
|
await Bun.write(join(dir, 'b.ts'), 'const b = 2;\n');
|
|
|
|
const out = await run(readManyFilesTool, { files: [{ path: 'a.ts' }, { path: 'b.ts' }] });
|
|
expect(out).toContain('===== a.ts =====');
|
|
expect(out).toContain('1: const a = 1;');
|
|
expect(out).toContain('===== b.ts =====');
|
|
expect(out).toContain('1: const b = 2;');
|
|
});
|
|
|
|
test('read_many_files keeps the order it was given', async () => {
|
|
await Bun.write(join(dir, 'first.ts'), 'x\n');
|
|
await Bun.write(join(dir, 'second.ts'), 'y\n');
|
|
|
|
const out = await run(readManyFilesTool, { files: [{ path: 'second.ts' }, { path: 'first.ts' }] });
|
|
expect(out.indexOf('second.ts')).toBeLessThan(out.indexOf('first.ts'));
|
|
});
|
|
|
|
test('read_many_files honours a per-file offset and limit', async () => {
|
|
await Bun.write(join(dir, 'long.ts'), 'one\ntwo\nthree\nfour\n');
|
|
const out = await run(readManyFilesTool, { files: [{ path: 'long.ts', offset: 2, limit: 2 }] });
|
|
expect(out).toContain('2: two');
|
|
expect(out).toContain('3: three');
|
|
expect(out).not.toContain('1: one');
|
|
expect(out).not.toContain('4: four');
|
|
});
|
|
|
|
test('an unreadable path is named in its own block without aborting the rest', async () => {
|
|
await Bun.write(join(dir, 'good.ts'), 'fine\n');
|
|
await Bun.write(join(dir, 'blob.bin'), new Uint8Array([0x00, 0x01, 0x02]));
|
|
|
|
const out = await run(readManyFilesTool, {
|
|
files: [{ path: 'good.ts' }, { path: 'gone.ts' }, { path: 'blob.bin' }],
|
|
});
|
|
|
|
expect(out).toContain('1: fine');
|
|
expect(out).toContain('===== gone.ts =====');
|
|
expect(out).toContain('No such file: gone.ts');
|
|
expect(out).toContain('binary file');
|
|
});
|
|
|
|
test('read_many_files refuses a path outside the workspace', async () => {
|
|
expect(run(readManyFilesTool, { files: [{ path: '../escape.ts' }] })).resolves.toContain('escapes workspace');
|
|
});
|
|
|
|
test('edit_file replaces a unique occurrence', async () => {
|
|
await Bun.write(join(dir, 'x.ts'), 'const a = 1;\nconst b = 2;\n');
|
|
await run(editFileTool, { path: 'x.ts', oldString: 'const b = 2;', newString: 'const b = 3;' });
|
|
expect(await Bun.file(join(dir, 'x.ts')).text()).toBe('const a = 1;\nconst b = 3;\n');
|
|
});
|
|
|
|
test('edit_file refuses ambiguous oldString unless replaceAll', async () => {
|
|
await Bun.write(join(dir, 'y.ts'), 'x\nx\n');
|
|
expect(run(editFileTool, { path: 'y.ts', oldString: 'x', newString: 'z' })).rejects.toThrow(/appears 2 times/);
|
|
|
|
await run(editFileTool, { path: 'y.ts', oldString: 'x', newString: 'z', replaceAll: true });
|
|
expect(await Bun.file(join(dir, 'y.ts')).text()).toBe('z\nz\n');
|
|
});
|
|
|
|
test('edit_file accepts snake_case params from models that emit them', async () => {
|
|
const parsed = (editFileTool.inputSchema as z.ZodType).parse({
|
|
path: 'x.ts',
|
|
old_string: 'const b = 2;',
|
|
new_string: 'const b = 3;',
|
|
replace_all: false,
|
|
});
|
|
expect(parsed).toMatchObject({ oldString: 'const b = 2;', newString: 'const b = 3;', replaceAll: false });
|
|
});
|
|
|
|
test('multi_edit accepts snake_case params on its edits array', async () => {
|
|
const parsed = (multiEditTool.inputSchema as z.ZodType).parse({
|
|
path: 'a.ts',
|
|
edits: [{ old_string: 'const a = 1;', new_string: 'const a = 10;' }],
|
|
});
|
|
expect(parsed).toMatchObject({ edits: [{ oldString: 'const a = 1;', newString: 'const a = 10;' }] });
|
|
});
|
|
|
|
test('edit_file reports a missing oldString', async () => {
|
|
await Bun.write(join(dir, 'z.ts'), 'hello');
|
|
expect(run(editFileTool, { path: 'z.ts', oldString: 'bye', newString: 'hi' })).rejects.toThrow(/not found/);
|
|
});
|
|
|
|
test('write_file then glob and grep find the content', async () => {
|
|
await run(writeFileTool, { path: 'src/app.ts', content: 'export const port = 8080;\n' });
|
|
expect(await run(globTool, { pattern: 'src/**/*.ts' })).toBe('src/app.ts');
|
|
expect(await run(grepTool, { pattern: 'port = \\d+', include: '**/*.ts' })).toBe(
|
|
'src/app.ts:1: export const port = 8080;',
|
|
);
|
|
});
|
|
|
|
test('overwriting a file with collapsed whitespace is flagged in the result', async () => {
|
|
const before = [
|
|
"@extends('layouts.app')",
|
|
"@section('content')",
|
|
'<section class="page-hero">',
|
|
' <div class="container">',
|
|
' <p>Explore homes</p>',
|
|
' </div>',
|
|
'</section>',
|
|
'@endsection',
|
|
'',
|
|
].join('\n');
|
|
await Bun.write(join(dir, 'index.blade.php'), before);
|
|
|
|
// What a compressed rewrite looks like: the same markup, most newlines gone.
|
|
const collapsed = before.replace(/\n\s*/g, '');
|
|
const out = await run(writeFileTool, { path: 'index.blade.php', content: collapsed });
|
|
|
|
expect(out).toContain('Wrote');
|
|
expect(out).toContain('newline');
|
|
expect(await Bun.file(join(dir, 'index.blade.php')).text()).toBe(collapsed);
|
|
});
|
|
|
|
test('a normal rewrite is not flagged', async () => {
|
|
await Bun.write(join(dir, 'a.ts'), 'const a = 1;\nconst b = 2;\n');
|
|
const out = await run(writeFileTool, { path: 'a.ts', content: 'const a = 10;\nconst b = 20;\n' });
|
|
expect(out).toBe('Wrote 28 chars to a.ts');
|
|
|
|
// And a genuine deletion is not either: fewer lines is fine when the content
|
|
// is also much shorter — the flag is for whitespace collapse, not truncation.
|
|
await Bun.write(join(dir, 'b.ts'), 'line 1\nline 2\nline 3\n');
|
|
const short = await run(writeFileTool, { path: 'b.ts', content: 'line 1\n' });
|
|
expect(short).toBe('Wrote 7 chars to b.ts');
|
|
});
|
|
|
|
test('move_file renames a file and creates the parent directory', async () => {
|
|
await Bun.write(join(dir, 'old.ts'), 'export const a = 1;\n');
|
|
|
|
const out = await run(moveFileTool, { from: 'old.ts', to: 'src/new.ts' });
|
|
|
|
expect(out).toContain('old.ts');
|
|
expect(out).toContain('src/new.ts');
|
|
expect(await Bun.file(join(dir, 'src/new.ts')).text()).toBe('export const a = 1;\n');
|
|
expect(await Bun.file(join(dir, 'old.ts')).exists()).toBe(false);
|
|
});
|
|
|
|
test('move_file refuses a missing source and an occupied target', async () => {
|
|
await Bun.write(join(dir, 'one.ts'), 'a\n');
|
|
await Bun.write(join(dir, 'two.ts'), 'b\n');
|
|
|
|
expect(run(moveFileTool, { from: 'gone.ts', to: 'x.ts' })).rejects.toThrow(/no such file/i);
|
|
expect(run(moveFileTool, { from: 'one.ts', to: 'two.ts' })).rejects.toThrow(/already exists/i);
|
|
|
|
// Neither refusal may have touched anything.
|
|
expect(await Bun.file(join(dir, 'one.ts')).text()).toBe('a\n');
|
|
expect(await Bun.file(join(dir, 'two.ts')).text()).toBe('b\n');
|
|
});
|
|
|
|
test('move_file refuses either path outside the workspace', async () => {
|
|
await Bun.write(join(dir, 'in.ts'), 'x\n');
|
|
expect(run(moveFileTool, { from: 'in.ts', to: '../escaped.ts' })).rejects.toThrow(/escapes workspace/);
|
|
expect(run(moveFileTool, { from: '../../etc/passwd', to: 'here.ts' })).rejects.toThrow(/escapes workspace/);
|
|
});
|
|
|
|
test('delete_file removes one file and reports it', async () => {
|
|
await Bun.write(join(dir, 'gone.ts'), 'x\n');
|
|
|
|
const out = await run(deleteFileTool, { path: 'gone.ts' });
|
|
|
|
expect(out).toContain('gone.ts');
|
|
expect(await Bun.file(join(dir, 'gone.ts')).exists()).toBe(false);
|
|
});
|
|
|
|
test('delete_file refuses a missing file, a directory, and an escaping path', async () => {
|
|
await Bun.write(join(dir, 'sub/keep.ts'), 'x\n');
|
|
|
|
expect(run(deleteFileTool, { path: 'nope.ts' })).rejects.toThrow(/no such file/i);
|
|
// A directory delete is recursive by nature, which is the one thing this must
|
|
// not do quietly: that is the guard plugin's `rm -rf` case.
|
|
expect(run(deleteFileTool, { path: 'sub' })).rejects.toThrow(/directory/i);
|
|
expect(run(deleteFileTool, { path: '../outside.ts' })).rejects.toThrow(/escapes workspace/);
|
|
|
|
expect(await Bun.file(join(dir, 'sub/keep.ts')).exists()).toBe(true);
|
|
});
|
|
|
|
test('both new write tools are gated and belong to a set', () => {
|
|
for (const name of ['move_file', 'delete_file']) {
|
|
expect(MUTATING_TOOLS as readonly string[]).toContain(name);
|
|
expect(toolSetOf(name)).toBe('edit-plus');
|
|
expect(Object.keys(tools)).toContain(name);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The two halves of the derivation, checked from both ends.
|
|
*
|
|
* `MUTATING_TOOLS` is built by filtering the registry, so the risk is no longer a
|
|
* name missing from a hand-typed list — it is a tool that *should* be marked and is
|
|
* not, which derives a list that silently omits a write. These assert the sets agree
|
|
* with the source in both directions, so adding a write tool without marking it
|
|
* fails here rather than in a user's workspace.
|
|
*/
|
|
const WRITE_TOOL_HINTS = ['write', 'edit', 'delete', 'move', 'insert', 'replace', 'append', 'prepend', 'patch', 'bash'];
|
|
// bash_status and bash_stop manage a background process; they do not mutate the
|
|
// workspace, so they stay out of MUTATING_TOOLS.
|
|
const BASH_CONTROL_TOOLS = new Set(['bash_status', 'bash_stop']);
|
|
|
|
test('every tool whose name implies a write is marked mutating', () => {
|
|
const unmarked = Object.keys(tools).filter(
|
|
(name) => WRITE_TOOL_HINTS.some((hint) => name.includes(hint)) && !(MUTATING_TOOLS as readonly string[]).includes(name) && !BASH_CONTROL_TOOLS.has(name),
|
|
);
|
|
expect(unmarked).toEqual([]);
|
|
});
|
|
|
|
test('every mutating tool is registered, so nothing is marked in the abstract', () => {
|
|
const names = new Set(Object.keys(tools));
|
|
for (const name of MUTATING_TOOLS) expect(names.has(name), name).toBe(true);
|
|
});
|
|
|
|
test('every registered tool belongs to a set or is explicitly session-level', () => {
|
|
// MCP, plugin, and session tools are namespaced or added at runtime and are not
|
|
// part of the schema budget; a bare built-in with no set would never be listed by
|
|
// /tools and could not be switched off.
|
|
const sessionLevel = new Set(['todo_write', 'remember', 'recall', 'forget', 'skill', 'ask', 'current_time']);
|
|
const inNoSet = Object.keys(tools).filter((name) => toolSetOf(name) === undefined && !sessionLevel.has(name));
|
|
expect(inNoSet).toEqual([]);
|
|
});
|
|
|
|
test('every set name is reachable and every static set member is a real tool', () => {
|
|
const names = new Set(Object.keys(tools));
|
|
// Created per-session because it needs a model to write the message; it is a real
|
|
// member of the `git` set but lives outside the static registry in cli.tsx.
|
|
const dynamic = new Set(['git_commit_message']);
|
|
for (const set of TOOL_SET_NAMES) {
|
|
expect(TOOL_SETS[set].length).toBeGreaterThan(0);
|
|
for (const member of TOOL_SETS[set]) {
|
|
if (dynamic.has(member)) continue;
|
|
expect(names.has(member), `${set} lists ${member}`).toBe(true);
|
|
expect(toolSetOf(member), member).toBe(set);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('multi_edit applies every edit in order, each seeing the last', async () => {
|
|
await Bun.write(join(dir, 'm.ts'), 'const a = 1;\nconst b = 2;\n');
|
|
const out = await run(multiEditTool, {
|
|
path: 'm.ts',
|
|
edits: [
|
|
{ oldString: 'const a = 1;', newString: 'const a = 10;' },
|
|
{ oldString: 'const a = 10;\nconst b = 2;', newString: 'const a = 10;\nconst b = 20;' },
|
|
],
|
|
});
|
|
|
|
expect(out).toContain('2 edit(s)');
|
|
expect(await Bun.file(join(dir, 'm.ts')).text()).toBe('const a = 10;\nconst b = 20;\n');
|
|
});
|
|
|
|
test('a failing second edit leaves the file exactly as it was', async () => {
|
|
const before = 'const a = 1;\nconst b = 2;\n';
|
|
await Bun.write(join(dir, 'm.ts'), before);
|
|
|
|
expect(
|
|
run(multiEditTool, {
|
|
path: 'm.ts',
|
|
edits: [
|
|
{ oldString: 'const a = 1;', newString: 'const a = 10;' },
|
|
{ oldString: 'const NOPE = 0;', newString: 'x' },
|
|
],
|
|
}),
|
|
).rejects.toThrow(/edit 2: oldString not found/);
|
|
|
|
await Bun.sleep(20);
|
|
expect(await Bun.file(join(dir, 'm.ts')).text()).toBe(before);
|
|
});
|
|
|
|
test('multi_edit refuses an ambiguous match unless replaceAll, writing nothing', async () => {
|
|
const before = 'x\nx\n';
|
|
await Bun.write(join(dir, 'a.ts'), before);
|
|
|
|
expect(
|
|
run(multiEditTool, { path: 'a.ts', edits: [{ oldString: 'x', newString: 'y' }] }),
|
|
).rejects.toThrow(/appears 2 times/);
|
|
await Bun.sleep(20);
|
|
expect(await Bun.file(join(dir, 'a.ts')).text()).toBe(before);
|
|
|
|
await run(multiEditTool, { path: 'a.ts', edits: [{ oldString: 'x', newString: 'y', replaceAll: true }] });
|
|
expect(await Bun.file(join(dir, 'a.ts')).text()).toBe('y\ny\n');
|
|
});
|
|
|
|
test('multi_edit reports a missing file rather than creating one', async () => {
|
|
expect(
|
|
run(multiEditTool, { path: 'gone.ts', edits: [{ oldString: 'a', newString: 'b' }] }),
|
|
).rejects.toThrow(/No such file/);
|
|
expect(await Bun.file(join(dir, 'gone.ts')).exists()).toBe(false);
|
|
});
|
|
|
|
test('list_dir shows a tree with sizes and marks directories', async () => {
|
|
await Bun.write(join(dir, 'src/app.ts'), 'x'.repeat(2048));
|
|
await Bun.write(join(dir, 'readme.md'), 'hi');
|
|
|
|
const out = await run(listDirTool, {});
|
|
expect(out).toContain('src/');
|
|
expect(out).toContain('app.ts');
|
|
expect(out).toContain('2K');
|
|
expect(out).toContain('readme.md 2B');
|
|
});
|
|
|
|
test('list_dir stops at the depth limit, still naming the directory', async () => {
|
|
await Bun.write(join(dir, 'a/b/c/deep.ts'), 'x');
|
|
|
|
const shallow = await run(listDirTool, { depth: 1 });
|
|
expect(shallow).toContain('a/');
|
|
expect(shallow).not.toContain('deep.ts');
|
|
|
|
expect(await run(listDirTool, { depth: 4 })).toContain('deep.ts');
|
|
});
|
|
|
|
test('list_dir honours .gitignore and includeIgnored', async () => {
|
|
await Bun.write(join(dir, '.gitignore'), 'dist/\n');
|
|
await Bun.write(join(dir, 'dist/bundle.js'), 'x');
|
|
await Bun.write(join(dir, 'src/app.ts'), 'x');
|
|
|
|
expect(await run(listDirTool, {})).not.toContain('bundle.js');
|
|
expect(await run(listDirTool, { includeIgnored: true })).toContain('bundle.js');
|
|
});
|
|
|
|
test('list_dir scopes to a subdirectory and refuses a file', async () => {
|
|
await Bun.write(join(dir, 'src/app.ts'), 'x');
|
|
await Bun.write(join(dir, 'other.ts'), 'x');
|
|
|
|
const out = await run(listDirTool, { path: 'src' });
|
|
expect(out).toContain('app.ts');
|
|
expect(out).not.toContain('other.ts');
|
|
|
|
expect(run(listDirTool, { path: 'other.ts' })).rejects.toThrow(/Not a directory/);
|
|
});
|
|
|
|
const PATCH_ADD = `*** Add File: src/new.ts
|
|
+export const a = 1;
|
|
+export const b = 2;`;
|
|
|
|
test('parsePatch reads add, update, move, and delete markers', () => {
|
|
const ops = parsePatch(`${PATCH_ADD}
|
|
*** Update File: src/old.ts
|
|
*** Move to: src/renamed.ts
|
|
-const port = 8080;
|
|
+const port = 9090;
|
|
*** Delete File: src/gone.ts`);
|
|
|
|
expect(ops).toEqual([
|
|
{ kind: 'add', path: 'src/new.ts', content: 'export const a = 1;\nexport const b = 2;' },
|
|
{
|
|
kind: 'update',
|
|
path: 'src/old.ts',
|
|
moveTo: 'src/renamed.ts',
|
|
oldString: 'const port = 8080;',
|
|
newString: 'const port = 9090;',
|
|
},
|
|
{ kind: 'delete', path: 'src/gone.ts' },
|
|
]);
|
|
});
|
|
|
|
test('parsePatch rejects a malformed envelope rather than guessing', () => {
|
|
expect(() => parsePatch('')).toThrow(/empty/);
|
|
expect(() => parsePatch('just some text')).toThrow(/not a marker/);
|
|
expect(() => parsePatch('*** Update File: a.ts\n+only additions')).toThrow(/at least one - line/);
|
|
expect(() => parsePatch('*** Add File: a.ts\n-removing')).toThrow(/cannot remove lines/);
|
|
expect(() => parsePatch('*** Add File: a.ts\n*** Move to: b.ts\n+x')).toThrow(/only valid on an Update/);
|
|
expect(() => parsePatch('*** Update File: a.ts\nno prefix here')).toThrow(/neither \+ nor -/);
|
|
});
|
|
|
|
test('apply_patch adds, updates, moves, and deletes in one call', async () => {
|
|
await Bun.write(join(dir, 'old.ts'), 'const port = 8080;\n');
|
|
await Bun.write(join(dir, 'gone.ts'), 'obsolete\n');
|
|
await Bun.write(join(dir, 'moving.ts'), 'const name = "a";\n');
|
|
|
|
const out = await run(applyPatchTool, {
|
|
patch: `*** Add File: fresh.ts
|
|
+export const fresh = true;
|
|
*** Update File: old.ts
|
|
-const port = 8080;
|
|
+const port = 9090;
|
|
*** Update File: moving.ts
|
|
*** Move to: moved.ts
|
|
-const name = "a";
|
|
+const name = "b";
|
|
*** Delete File: gone.ts`,
|
|
});
|
|
|
|
expect(out).toContain('4 changes');
|
|
expect(await Bun.file(join(dir, 'fresh.ts')).text()).toBe('export const fresh = true;\n');
|
|
expect(await Bun.file(join(dir, 'old.ts')).text()).toBe('const port = 9090;\n');
|
|
expect(await Bun.file(join(dir, 'moved.ts')).text()).toBe('const name = "b";\n');
|
|
expect(await Bun.file(join(dir, 'moving.ts')).exists()).toBe(false);
|
|
expect(await Bun.file(join(dir, 'gone.ts')).exists()).toBe(false);
|
|
});
|
|
|
|
test('a patch that fails on its third file writes nothing at all', async () => {
|
|
await Bun.write(join(dir, 'one.ts'), 'const a = 1;\n');
|
|
await Bun.write(join(dir, 'two.ts'), 'const b = 2;\n');
|
|
|
|
expect(
|
|
run(applyPatchTool, {
|
|
patch: `*** Update File: one.ts
|
|
-const a = 1;
|
|
+const a = 10;
|
|
*** Update File: two.ts
|
|
-const b = 2;
|
|
+const b = 20;
|
|
*** Update File: three.ts
|
|
-const c = 3;
|
|
+const c = 30;`,
|
|
}),
|
|
).rejects.toThrow(/no such file/);
|
|
|
|
await Bun.sleep(20);
|
|
// Atomicity is the whole reason this tool exists rather than three edit_file
|
|
// calls, so the first two files must be untouched.
|
|
expect(await Bun.file(join(dir, 'one.ts')).text()).toBe('const a = 1;\n');
|
|
expect(await Bun.file(join(dir, 'two.ts')).text()).toBe('const b = 2;\n');
|
|
});
|
|
|
|
test('apply_patch refuses an ambiguous match without writing', async () => {
|
|
await Bun.write(join(dir, 'dup.ts'), 'x\nx\n');
|
|
expect(run(applyPatchTool, { patch: '*** Update File: dup.ts\n-x\n+y' })).rejects.toThrow(/appear 2 times/);
|
|
await Bun.sleep(20);
|
|
expect(await Bun.file(join(dir, 'dup.ts')).text()).toBe('x\nx\n');
|
|
});
|
|
|
|
test('apply_patch refuses to add over an existing file', async () => {
|
|
await Bun.write(join(dir, 'here.ts'), 'original\n');
|
|
expect(run(applyPatchTool, { patch: '*** Add File: here.ts\n+replacement' })).rejects.toThrow(/already exists/);
|
|
await Bun.sleep(20);
|
|
expect(await Bun.file(join(dir, 'here.ts')).text()).toBe('original\n');
|
|
});
|
|
|
|
test('apply_patch refuses to move onto an existing file', async () => {
|
|
await Bun.write(join(dir, 'from.ts'), 'const a = 1;\n');
|
|
await Bun.write(join(dir, 'to.ts'), 'occupied\n');
|
|
|
|
expect(
|
|
run(applyPatchTool, { patch: '*** Update File: from.ts\n*** Move to: to.ts\n-const a = 1;\n+const a = 2;' }),
|
|
).rejects.toThrow(/already exists/);
|
|
await Bun.sleep(20);
|
|
expect(await Bun.file(join(dir, 'from.ts')).text()).toBe('const a = 1;\n');
|
|
expect(await Bun.file(join(dir, 'to.ts')).text()).toBe('occupied\n');
|
|
});
|
|
|
|
test('apply_patch refuses the same file twice in one patch', async () => {
|
|
await Bun.write(join(dir, 'twice.ts'), 'a\nb\n');
|
|
expect(
|
|
run(applyPatchTool, { patch: '*** Update File: twice.ts\n-a\n+A\n*** Update File: twice.ts\n-b\n+B' }),
|
|
).rejects.toThrow(/appears twice/);
|
|
});
|
|
|
|
test('apply_patch refuses a path outside the workspace', async () => {
|
|
expect(run(applyPatchTool, { patch: '*** Add File: ../escape.ts\n+x' })).rejects.toThrow(/escapes workspace/);
|
|
});
|
|
|
|
test('an update that removes lines and adds none deletes them', async () => {
|
|
await Bun.write(join(dir, 'trim.ts'), 'keep\nremove me\nkeep too\n');
|
|
await run(applyPatchTool, { patch: '*** Update File: trim.ts\n-remove me\n' });
|
|
expect(await Bun.file(join(dir, 'trim.ts')).text()).toBe('keep\n\nkeep too\n');
|
|
});
|
|
|
|
test('glob skips gitignored paths and honours includeIgnored', async () => {
|
|
await Bun.write(join(dir, '.gitignore'), 'dist/\n');
|
|
await Bun.write(join(dir, 'dist/app.js'), 'x');
|
|
await Bun.write(join(dir, 'src/app.js'), 'x');
|
|
|
|
expect(await run(globTool, { pattern: '**/*.js' })).toBe('src/app.js');
|
|
const both = await run(globTool, { pattern: '**/*.js', includeIgnored: true });
|
|
expect(both.split('\n').sort()).toEqual(['dist/app.js', 'src/app.js']);
|
|
});
|
|
|
|
test('grep skips gitignored paths and honours includeIgnored', async () => {
|
|
await Bun.write(join(dir, '.gitignore'), 'vendor/\n');
|
|
await Bun.write(join(dir, 'vendor/lib.ts'), 'const needle = 1;\n');
|
|
await Bun.write(join(dir, 'src/own.ts'), 'const needle = 2;\n');
|
|
|
|
const clean = await run(grepTool, { pattern: 'needle' });
|
|
expect(clean).toContain('src/own.ts');
|
|
expect(clean).not.toContain('vendor/lib.ts');
|
|
|
|
const all = await run(grepTool, { pattern: 'needle', includeIgnored: true });
|
|
expect(all).toContain('vendor/lib.ts');
|
|
});
|
|
|
|
test('grep skips binaries', async () => {
|
|
await Bun.write(join(dir, 'blob.bin'), new Uint8Array([0x00, 0x6e, 0x65, 0x65, 0x64, 0x6c, 0x65]));
|
|
await Bun.write(join(dir, 'code.ts'), 'needle\n');
|
|
const out = await run(grepTool, { pattern: 'needle' });
|
|
expect(out).toContain('code.ts');
|
|
expect(out).not.toContain('blob.bin');
|
|
});
|
|
|
|
test('grep honours ignoreCase', async () => {
|
|
await Bun.write(join(dir, 'c.ts'), 'NEEDLE\n');
|
|
expect(await run(grepTool, { pattern: 'needle' })).toBe('No matches.');
|
|
expect(await run(grepTool, { pattern: 'needle', ignoreCase: true })).toContain('c.ts:1: NEEDLE');
|
|
});
|
|
|
|
test('grep reports no matches rather than an empty string', async () => {
|
|
await Bun.write(join(dir, 'a.ts'), 'nothing here\n');
|
|
expect(await run(grepTool, { pattern: 'zzzznope' })).toBe('No matches.');
|
|
});
|
|
|
|
test('grep rejects an invalid regex instead of crashing the loop', async () => {
|
|
await Bun.write(join(dir, 'a.ts'), 'x\n');
|
|
expect(run(grepTool, { pattern: '([' })).rejects.toThrow(/Invalid regex/);
|
|
});
|
|
|
|
test('bash returns the exit code and captured output', async () => {
|
|
const out = await run(bashTool, { command: 'echo hello' });
|
|
expect(out).toContain('exit: 0');
|
|
expect(out).toContain('hello');
|
|
});
|
|
|
|
test('bash reports a non-zero exit code', async () => {
|
|
const out = await run(bashTool, { command: 'exit 3' });
|
|
expect(out).toContain('exit: 3');
|
|
});
|
|
|
|
test('bash streams output to the listener before the command exits', async () => {
|
|
const chunks: { id: string; text: string }[] = [];
|
|
onBashOutput(({ toolCallId, chunk }) => chunks.push({ id: toolCallId, text: chunk }));
|
|
|
|
const script =
|
|
process.platform === 'win32'
|
|
? // Unconditional sequencing: a blocked loopback makes `ping` exit 1, and `&&`
|
|
// would then skip `echo second` on machines where ICMP is filtered.
|
|
'echo first & ping -n 2 127.0.0.1 > nul & echo second'
|
|
: 'echo first; sleep 0.4; echo second';
|
|
await run(bashTool, { command: script });
|
|
|
|
expect(chunks.length).toBeGreaterThan(0);
|
|
expect(chunks.every((c) => c.id === 't1')).toBe(true);
|
|
const streamed = chunks.map((c) => c.text).join('');
|
|
expect(streamed).toContain('first');
|
|
expect(streamed).toContain('second');
|
|
}, 20_000);
|
|
|
|
test('the bash listener is cleared when unset', async () => {
|
|
const chunks: string[] = [];
|
|
onBashOutput(({ chunk }) => chunks.push(chunk));
|
|
await run(bashTool, { command: 'echo one' });
|
|
const afterFirst = chunks.length;
|
|
|
|
onBashOutput(undefined);
|
|
await run(bashTool, { command: 'echo two' });
|
|
expect(chunks.length).toBe(afterFirst);
|
|
}, 20_000);
|
|
|
|
const sleeper = process.platform === 'win32' ? 'ping -n 20 127.0.0.1 > nul' : 'sleep 20';
|
|
|
|
test('interruptBash kills the command in flight and names it', async () => {
|
|
const started = Date.now();
|
|
const call = run(bashTool, { command: sleeper, timeout: 30_000 });
|
|
|
|
await Bun.sleep(400);
|
|
expect(interruptBash()).toEqual([sleeper]);
|
|
|
|
expect(call).rejects.toThrow(/user interrupted this command/i);
|
|
await call.catch(() => {});
|
|
// Killed, not waited out: the 20s command must not have run to completion.
|
|
expect(Date.now() - started).toBeLessThan(10_000);
|
|
}, 30_000);
|
|
|
|
test('the interrupt error carries whatever the command printed first', async () => {
|
|
const script =
|
|
process.platform === 'win32' ? 'echo before && ping -n 20 127.0.0.1 > nul' : 'echo before; sleep 20';
|
|
const call = run(bashTool, { command: script, timeout: 30_000 });
|
|
|
|
await Bun.sleep(600);
|
|
interruptBash();
|
|
|
|
const message = await call.then(() => '', (e: Error) => e.message);
|
|
expect(message).toContain('before');
|
|
expect(message).toContain('effects are unknown');
|
|
}, 30_000);
|
|
|
|
test('interruptBash with nothing running is a no-op', () => {
|
|
expect(interruptBash()).toEqual([]);
|
|
});
|
|
|
|
test('a command that finished is no longer interruptible', async () => {
|
|
await run(bashTool, { command: 'echo done' });
|
|
expect(interruptBash()).toEqual([]);
|
|
}, 20_000);
|
|
|
|
// --- background commands (dev servers, watchers) ---
|
|
|
|
const bgScript = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.6; echo bg-done';
|
|
|
|
test('bash background returns immediately with a handle', async () => {
|
|
const started = Date.now();
|
|
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'sleeper' });
|
|
// The counter is module-global, so the number depends on what ran before in
|
|
// the same process — extract it rather than assuming it is 1.
|
|
const handle = Number(out.match(/background (\d+):/)?.[1]);
|
|
expect(handle).toBeGreaterThan(0);
|
|
expect(out).toContain('running');
|
|
expect(out).toContain('bash_status');
|
|
// Returns before the 30s command finishes.
|
|
expect(Date.now() - started).toBeLessThan(5_000);
|
|
await shutdownBackgrounds();
|
|
}, 20_000);
|
|
|
|
test('bash_status reports running then finished with the exit code', async () => {
|
|
const out = await run(bashTool, { command: bgScript, background: true, name: 'bgtest' });
|
|
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
|
expect(handle).toBeGreaterThan(0);
|
|
|
|
const early = statusBackground(handle);
|
|
expect(early).toContain('running');
|
|
|
|
// Wait for the script to finish.
|
|
let status = '';
|
|
const deadline = Date.now() + 10_000;
|
|
while (Date.now() < deadline) {
|
|
await Bun.sleep(200);
|
|
status = statusBackground(handle);
|
|
if (status.includes('finished')) break;
|
|
}
|
|
expect(status).toContain('finished');
|
|
expect(status).toContain('exit 0');
|
|
await shutdownBackgrounds();
|
|
}, 25_000);
|
|
|
|
test('bash_status unknown handle reports no such handle', async () => {
|
|
expect(statusBackground(999)).toContain('no such handle');
|
|
});
|
|
|
|
test('bash_stop kills a running background command', async () => {
|
|
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'killer' });
|
|
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
|
const started = Date.now();
|
|
const msg = await run(bashStopTool, { handle });
|
|
expect(msg).toContain(`killed ${handle}`);
|
|
expect(msg).toContain('sleep 30');
|
|
// Killed, not waited out.
|
|
expect(Date.now() - started).toBeLessThan(10_000);
|
|
await shutdownBackgrounds();
|
|
}, 25_000);
|
|
|
|
test('bash_stop unknown handle is reported without throwing', async () => {
|
|
const out = await run(bashStopTool, { handle: 4242 });
|
|
expect(out).toContain('no such handle');
|
|
});
|
|
|
|
test('bash_status streams output into its buffer for a later read', async () => {
|
|
const out = await run(bashTool, { command: bgScript, background: true, name: 'stream' });
|
|
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
|
|
|
// Give the script time to print its line.
|
|
await Bun.sleep(1200);
|
|
const status = statusBackground(handle);
|
|
expect(status.toLowerCase()).toContain('bg-done');
|
|
await shutdownBackgrounds();
|
|
}, 20_000);
|
|
|
|
test('background commands are not interrupted by interruptBash (foreground)', async () => {
|
|
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'immune' });
|
|
await Bun.sleep(200);
|
|
// Foreground interrupt must not touch background commands.
|
|
expect(interruptBash()).toEqual([]);
|
|
const handle = Number(out.match(/background (\d+)/)?.[1]);
|
|
expect(statusBackground(handle)).toContain('running');
|
|
await shutdownBackgrounds();
|
|
}, 20_000);
|
|
|
|
test('shutdownBackgrounds kills everything live and can be called twice', async () => {
|
|
await run(bashTool, { command: 'sleep 30', background: true, name: 'cleanup' });
|
|
const before = Date.now();
|
|
await shutdownBackgrounds();
|
|
// Killed, not waited out.
|
|
expect(Date.now() - before).toBeLessThan(10_000);
|
|
// Second call is a no-op.
|
|
await shutdownBackgrounds();
|
|
}, 20_000);
|
|
|
|
test('timeout kills the command instead of hanging past its deadline', async () => {
|
|
const started = Date.now();
|
|
const call = run(bashTool, { command: sleeper, timeout: 1_000 });
|
|
const message = await call.then(() => '', (e: Error) => e.message);
|
|
expect(message).toMatch(/exceeded its 1000ms timeout/i);
|
|
expect(message).toContain('effects are unknown');
|
|
// The old bug: Bun's spawn timeout killed only the shell, the grandchild kept
|
|
// the pipes open, and this hung for the full 20s instead.
|
|
expect(Date.now() - started).toBeLessThan(10_000);
|
|
}, 30_000);
|
|
|
|
test('an aborted turn tree-kills the command, not just the shell', async () => {
|
|
const started = Date.now();
|
|
const controller = new AbortController();
|
|
const call = run(
|
|
bashTool,
|
|
{ command: sleeper, timeout: 30_000 },
|
|
{ toolCallId: 'a1', messages: [], abortSignal: controller.signal },
|
|
);
|
|
|
|
await Bun.sleep(400);
|
|
controller.abort();
|
|
|
|
const message = await call.then(() => '', (e: Error) => e.message);
|
|
expect(message).toMatch(/user interrupted/i);
|
|
expect(Date.now() - started).toBeLessThan(10_000);
|
|
// The entry must be gone: a stale `running` row is what made every ctrl-c
|
|
// re-announce an already-dead command.
|
|
expect(interruptBash()).toEqual([]);
|
|
}, 30_000);
|