Files
shiro-neko/test/tools-git.test.ts
T
Muhammad Zakir Ramadhan 2fa6ee247b Add batch reads, @file completion, interruptible commands, tool sets
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.
2026-09-03 01:37:48 +07:00

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();
});