Tools, six built-in to fourteen: - read_many_files: up to 20 paths read concurrently, each with its own window. An unreadable path is reported in its own block instead of throwing. - multi_edit: several edits to one file, validated in memory first so a late failure cannot leave the file half-written. - list_dir: ignore-aware depth-limited tree. - git_status/diff/log/show/blame: read-only, spawned with a fixed argv rather than a shell string, which is what makes them safe to auto-approve. toolSets gates them. core is always on; edit-plus and git are optional. A disabled set reaches neither the wire nor the system prompt, since a prompt naming an absent tool teaches calls that cannot succeed. Interface: - Reasoning streams to a collapsed panel, ctrl-r expands, dropped when the turn ends: it is progress, not the answer. - The tool in flight is named from tool-input-start, before its arguments finish streaming, and cleared on its result. - Prompts typed mid-turn queue and drain in order. esc clears the queue as well as aborting. - @ opens a path picker fed by the ignore-aware walker. Prefix matches rank above substring matches, so @src/ means "under src/". The walk runs on the first @, not at startup. ctrl-c kills the command in flight and keeps the turn. The call throws rather than returning, so the model cannot read a killed command as one that ran and failed on its own terms. The kill takes the whole process tree: killing cmd /c alone left the real command holding both pipes open, so the read never returned and the interrupt did nothing for 19 seconds. Two pruning fixes: - A tool result whose tool call was pruned is now dropped with it. Pruning counts messages, so the cut landed between an assistant tool-call and the tool message answering it, producing 400 "No tool call found for function call output with call_id ...". The reverse pairing is left alone: a call awaiting its result is what a suspended approval looks like. - ignore.ts called statFs without importing it, so walk() crashed on the first symlink. 482 tests, up from 404. Docs synced across README, ROADMAP, TODO, and all of docs/: tool sets, the new tools, ctrl-c semantics, the tool-start event, and the two hand-maintained tool-name lists recorded as a known weakness.
151 lines
5.4 KiB
TypeScript
151 lines
5.4 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 {
|
|
GIT_TOOL_NAMES,
|
|
gitBlameTool,
|
|
gitDiffTool,
|
|
gitLogTool,
|
|
gitShowTool,
|
|
gitStatusTool,
|
|
gitTools,
|
|
} from '../src/tools-git';
|
|
|
|
let dir: string;
|
|
let origCwd: string;
|
|
|
|
const run = <T>(t: { execute?: (input: T, opts: any) => unknown }, input: T) =>
|
|
Promise.resolve(t.execute!(input, { toolCallId: 't1', messages: [] })) as Promise<string>;
|
|
|
|
async function git(...args: string[]): Promise<void> {
|
|
const proc = Bun.spawn(['git', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
|
|
const code = await proc.exited;
|
|
if (code !== 0) throw new Error(`git ${args.join(' ')} failed: ${await new Response(proc.stderr).text()}`);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
origCwd = process.cwd();
|
|
dir = mkdtempSync(join(tmpdir(), 'shiro-git-'));
|
|
process.chdir(dir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(origCwd);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function repoWithOneCommit(): Promise<void> {
|
|
await git('init', '-b', 'main');
|
|
await git('config', 'user.email', 'test@example.com');
|
|
await git('config', 'user.name', 'Test');
|
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 8080;\n');
|
|
await git('add', '.');
|
|
await git('commit', '-m', 'add the server port');
|
|
}
|
|
|
|
test('every git tool is registered and named consistently', () => {
|
|
expect(GIT_TOOL_NAMES.sort()).toEqual(['git_blame', 'git_diff', 'git_log', 'git_show', 'git_status']);
|
|
expect(Object.keys(gitTools).sort()).toEqual(GIT_TOOL_NAMES.sort());
|
|
});
|
|
|
|
test('git_status names the branch and describes each change', async () => {
|
|
await repoWithOneCommit();
|
|
expect(await run(gitStatusTool, {})).toContain('working tree clean');
|
|
|
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
|
await Bun.write(join(dir, 'new.ts'), 'x\n');
|
|
|
|
const out = await run(gitStatusTool, {});
|
|
expect(out).toContain('On main');
|
|
expect(out).toContain('app.ts');
|
|
expect(out).toContain('modified');
|
|
expect(out).toContain('new.ts');
|
|
expect(out).toContain('untracked');
|
|
});
|
|
|
|
test('git_status separates staged from unstaged', async () => {
|
|
await repoWithOneCommit();
|
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
|
await git('add', 'app.ts');
|
|
|
|
expect(await run(gitStatusTool, {})).toContain('staged modified');
|
|
});
|
|
|
|
test('git_diff shows the working tree, and staged on request', async () => {
|
|
await repoWithOneCommit();
|
|
expect(await run(gitDiffTool, {})).toBe('No uncommitted changes.');
|
|
|
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
|
const unstaged = await run(gitDiffTool, {});
|
|
expect(unstaged).toContain('-export const port = 8080;');
|
|
expect(unstaged).toContain('+export const port = 9090;');
|
|
|
|
expect(await run(gitDiffTool, { staged: true })).toBe('Nothing staged.');
|
|
await git('add', 'app.ts');
|
|
expect(await run(gitDiffTool, { staged: true })).toContain('9090');
|
|
});
|
|
|
|
test('git_diff narrows to a path', async () => {
|
|
await repoWithOneCommit();
|
|
await Bun.write(join(dir, 'app.ts'), 'changed\n');
|
|
await Bun.write(join(dir, 'other.ts'), 'also changed\n');
|
|
await git('add', 'other.ts');
|
|
await git('commit', '-m', 'add other');
|
|
await Bun.write(join(dir, 'other.ts'), 'changed again\n');
|
|
|
|
const out = await run(gitDiffTool, { path: 'app.ts' });
|
|
expect(out).toContain('app.ts');
|
|
expect(out).not.toContain('other.ts');
|
|
});
|
|
|
|
test('git_log lists commits newest first and honours the limit', async () => {
|
|
await repoWithOneCommit();
|
|
await Bun.write(join(dir, 'app.ts'), 'export const port = 9090;\n');
|
|
await git('commit', '-am', 'bump the port');
|
|
|
|
const out = await run(gitLogTool, {});
|
|
expect(out.split('\n')[0]).toContain('bump the port');
|
|
expect(out).toContain('add the server port');
|
|
expect(out).toContain('Test');
|
|
|
|
expect((await run(gitLogTool, { limit: 1 })).split('\n')).toHaveLength(1);
|
|
});
|
|
|
|
test('git_show renders one commit with its diff', async () => {
|
|
await repoWithOneCommit();
|
|
const out = await run(gitShowTool, { ref: 'HEAD' });
|
|
expect(out).toContain('add the server port');
|
|
expect(out).toContain('+export const port = 8080;');
|
|
});
|
|
|
|
test('git_show reports a bad ref rather than returning nothing', async () => {
|
|
await repoWithOneCommit();
|
|
expect(run(gitShowTool, { ref: 'no-such-ref' })).rejects.toThrow();
|
|
});
|
|
|
|
test('git_blame attributes each line and narrows by range', async () => {
|
|
await repoWithOneCommit();
|
|
const out = await run(gitBlameTool, { path: 'app.ts' });
|
|
expect(out).toContain('Test');
|
|
expect(out).toContain('export const port = 8080;');
|
|
|
|
expect(await run(gitBlameTool, { path: 'app.ts', startLine: 1, endLine: 1 })).toContain('8080');
|
|
});
|
|
|
|
test('outside a repository every tool fails with a clear message, not git porcelain', async () => {
|
|
for (const [name, t] of Object.entries(gitTools)) {
|
|
const input =
|
|
name === 'git_show' ? { ref: 'HEAD' } : name === 'git_blame' ? { path: 'nothing.ts' } : ({} as never);
|
|
expect(run(t as never, input as never), name).rejects.toThrow(/not a git repository/i);
|
|
}
|
|
});
|
|
|
|
test('an argument that looks like a shell injection is passed through as one argument', async () => {
|
|
await repoWithOneCommit();
|
|
// argv spawning, not a shell string, so this can only ever be a pathspec.
|
|
const out = await run(gitLogTool, { path: '; touch pwned.txt' }).catch((e: Error) => e.message);
|
|
expect(await Bun.file(join(dir, 'pwned.txt')).exists()).toBe(false);
|
|
expect(out).toBeTruthy();
|
|
});
|