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.
297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import { render } from 'ink-testing-library';
|
|
import React from 'react';
|
|
import { createAskTool } from '../src/ask';
|
|
import { applySubagentEvent } from '../src/ui/App';
|
|
import { AskPanel, createAskBridge } from '../src/ui/Ask';
|
|
import { Markdown } from '../src/ui/Markdown';
|
|
import { SubagentPanel, TodoPanel, StatusBar, InfoPanel, ActiveTool, QueuePanel, ThinkingPanel, type SubagentView } from '../src/ui/Panels';
|
|
import type { SubagentEvent } from '../src/subagent';
|
|
|
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
test('markdown renders headings, bullets, and code distinctly', () => {
|
|
const app = render(<Markdown text={'# Title\n\n- item\n\n```ts\ncode();\n```'} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('Title');
|
|
expect(frame).toContain('item');
|
|
expect(frame).toContain('code();');
|
|
expect(frame).toContain('ts');
|
|
app.unmount();
|
|
});
|
|
|
|
test('markdown strips the markup characters from the rendered output', () => {
|
|
const app = render(<Markdown text={'Use **bold** and `code` here.'} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('bold');
|
|
expect(frame).toContain('code');
|
|
expect(frame).not.toContain('**');
|
|
expect(frame).not.toContain('`');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the todo panel shows a progress bar and every status', () => {
|
|
const app = render(
|
|
<TodoPanel
|
|
todos={[
|
|
{ content: 'first', status: 'done' },
|
|
{ content: 'second', status: 'in_progress' },
|
|
{ content: 'third', status: 'pending' },
|
|
{ content: 'fourth', status: 'blocked', note: 'waiting on the API key' },
|
|
]}
|
|
width={10}
|
|
/>,
|
|
);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('1/4');
|
|
expect(frame).toContain('1 blocked');
|
|
expect(frame).toContain('[x] first');
|
|
expect(frame).toContain('[~] second');
|
|
expect(frame).toContain('[ ] third');
|
|
expect(frame).toContain('[!] fourth');
|
|
expect(frame).toContain('waiting on the API key');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the subagent panel shows the last few steps of a running agent', () => {
|
|
const agents: SubagentView[] = [
|
|
{
|
|
id: 'sub1',
|
|
kind: 'explore',
|
|
description: 'find auth handlers',
|
|
steps: [
|
|
{ tool: 'glob', summary: 'src/**/*.ts' },
|
|
{ tool: 'grep', summary: 'login' },
|
|
{ tool: 'read_file', summary: 'src/auth.ts' },
|
|
],
|
|
status: 'running',
|
|
},
|
|
];
|
|
const app = render(<SubagentPanel agents={agents} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('explore');
|
|
expect(frame).toContain('find auth handlers');
|
|
expect(frame).toContain('3 steps');
|
|
expect(frame).toContain('grep(login)');
|
|
app.unmount();
|
|
});
|
|
|
|
test('a failed subagent shows its error', () => {
|
|
const app = render(
|
|
<SubagentPanel
|
|
agents={[{ id: 's', kind: 'review', description: 'review diff', steps: [], status: 'failed', error: 'model refused' }]}
|
|
/>,
|
|
);
|
|
expect(app.lastFrame()).toContain('model refused');
|
|
app.unmount();
|
|
});
|
|
|
|
test('an empty subagent panel renders nothing', () => {
|
|
const app = render(<SubagentPanel agents={[]} />);
|
|
expect(app.lastFrame() ?? '').toBe('');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the status bar reports model, agent, thinking, context, and spend', () => {
|
|
const app = render(
|
|
<StatusBar model="gpt-5" agent="deep" thinking="max" contextTokens={1234} cost="$0.42" toolCount={13} />,
|
|
);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('gpt-5');
|
|
expect(frame).toContain('deep');
|
|
expect(frame).toContain('max');
|
|
expect(frame).toContain('1234');
|
|
expect(frame).toContain('$0.42');
|
|
expect(frame).toContain('13 tools');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the info panel renders a markdown body', () => {
|
|
const app = render(<InfoPanel title="tools" hint="7 offered" lines={'- `read_file`\n- `bash`'} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('tools');
|
|
expect(frame).toContain('7 offered');
|
|
expect(frame).toContain('read_file');
|
|
expect(frame).not.toContain('`');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the active tool line names the tool and the file it is touching', () => {
|
|
const app = render(<ActiveTool name="read_file" summary="src/session.ts" />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('read_file');
|
|
expect(frame).toContain('src/session.ts');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the active tool line renders before the arguments have arrived', () => {
|
|
const app = render(<ActiveTool name="grep" />);
|
|
expect(app.lastFrame()).toContain('grep');
|
|
app.unmount();
|
|
});
|
|
|
|
test('thinking collapses to a token count, and expands on request', () => {
|
|
const text = 'x'.repeat(1648);
|
|
const collapsed = render(<ThinkingPanel text={text} />);
|
|
expect(collapsed.lastFrame()).toContain('~412 tokens');
|
|
expect(collapsed.lastFrame()).not.toContain('xxxx');
|
|
collapsed.unmount();
|
|
|
|
const open = render(<ThinkingPanel text={'first line\nsecond line'} expanded />);
|
|
const frame = open.lastFrame() ?? '';
|
|
expect(frame).toContain('second line');
|
|
expect(frame).toContain('collapse');
|
|
open.unmount();
|
|
});
|
|
|
|
test('no reasoning renders nothing at all', () => {
|
|
const app = render(<ThinkingPanel text="" />);
|
|
expect(app.lastFrame() ?? '').toBe('');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the queue panel counts what is waiting and lists it in order', () => {
|
|
const app = render(<QueuePanel prompts={['first thing', 'second thing']} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('queued: 2');
|
|
expect(frame).toContain('1. first thing');
|
|
expect(frame).toContain('2. second thing');
|
|
app.unmount();
|
|
});
|
|
|
|
test('an empty queue renders nothing', () => {
|
|
const app = render(<QueuePanel prompts={[]} />);
|
|
expect(app.lastFrame() ?? '').toBe('');
|
|
app.unmount();
|
|
});
|
|
|
|
const events: SubagentEvent[] = [
|
|
{ type: 'start', id: 'a', kind: 'explore', description: 'first task' },
|
|
{ type: 'step', id: 'a', tool: 'grep', summary: 'needle' },
|
|
{ type: 'start', id: 'b', kind: 'review', description: 'second task' },
|
|
{ type: 'end', id: 'a', ok: true, steps: 1 },
|
|
{ type: 'error', id: 'b', message: 'exploded' },
|
|
];
|
|
|
|
test('subagent events fold into the panel view', () => {
|
|
const view = events.reduce(applySubagentEvent, [] as SubagentView[]);
|
|
expect(view).toHaveLength(2);
|
|
expect(view[0]).toMatchObject({ id: 'a', status: 'done', steps: [{ tool: 'grep', summary: 'needle' }] });
|
|
expect(view[1]).toMatchObject({ id: 'b', status: 'failed', error: 'exploded' });
|
|
});
|
|
|
|
test('an event for an unknown id is ignored rather than throwing', () => {
|
|
const view = applySubagentEvent([], { type: 'step', id: 'ghost', tool: 'grep', summary: 'x' });
|
|
expect(view).toEqual([]);
|
|
});
|
|
|
|
test('the ask tool refuses when nothing can answer', async () => {
|
|
const t = createAskTool(undefined);
|
|
expect(
|
|
Promise.resolve(t.execute!({ question: 'which one?' } as never, { toolCallId: 'x', messages: [] } as never)),
|
|
).rejects.toThrow(/headless/);
|
|
});
|
|
|
|
test('the ask tool returns the chosen options', async () => {
|
|
const t = createAskTool(async () => ['Option B']);
|
|
const out = await (t.execute!(
|
|
{ question: 'A or B?', options: [{ label: 'Option A' }, { label: 'Option B' }] } as never,
|
|
{ toolCallId: 'x', messages: [] } as never,
|
|
) as Promise<string>);
|
|
expect(out).toContain('Option B');
|
|
});
|
|
|
|
test('a dismissed question tells the model to decide for itself', async () => {
|
|
const t = createAskTool(async () => undefined);
|
|
const out = await (t.execute!({ question: 'anything?' } as never, {
|
|
toolCallId: 'x',
|
|
messages: [],
|
|
} as never) as Promise<string>);
|
|
expect(out).toContain('dismissed');
|
|
expect(out).toContain('best judgement');
|
|
});
|
|
|
|
test('the ask bridge resolves undefined when no UI is bound', async () => {
|
|
const bridge = createAskBridge();
|
|
expect(await bridge.ask({ question: 'q', multiple: false })).toBeUndefined();
|
|
});
|
|
|
|
test('the ask panel shows the question and its options', () => {
|
|
const app = render(
|
|
<AskPanel
|
|
pending={{
|
|
req: {
|
|
question: 'Which storage layer?',
|
|
options: [
|
|
{ label: 'Postgres', detail: 'relational, needs a migration' },
|
|
{ label: 'SQLite', detail: 'zero setup' },
|
|
],
|
|
multiple: false,
|
|
},
|
|
resolve: () => {},
|
|
}}
|
|
/>,
|
|
);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('shiro is asking');
|
|
expect(frame).toContain('Which storage layer?');
|
|
expect(frame).toContain('Postgres');
|
|
expect(frame).toContain('SQLite');
|
|
app.unmount();
|
|
});
|
|
|
|
test('choosing an option resolves the question', async () => {
|
|
const answers: (string[] | undefined)[] = [];
|
|
const app = render(
|
|
<AskPanel
|
|
pending={{
|
|
req: { question: 'A or B?', options: [{ label: 'A' }, { label: 'B' }], multiple: false },
|
|
resolve: (a) => answers.push(a),
|
|
}}
|
|
/>,
|
|
);
|
|
await wait(120);
|
|
app.stdin.write('\r');
|
|
await wait(200);
|
|
|
|
expect(answers).toEqual([['A']]);
|
|
app.unmount();
|
|
}, 10_000);
|
|
|
|
test('esc dismisses the question with no answer', async () => {
|
|
const answers: (string[] | undefined)[] = [];
|
|
const app = render(
|
|
<AskPanel
|
|
pending={{
|
|
req: { question: 'anything?', options: [{ label: 'A' }], multiple: false },
|
|
resolve: (a) => answers.push(a),
|
|
}}
|
|
/>,
|
|
);
|
|
await wait(120);
|
|
app.stdin.write('\u001B');
|
|
await wait(200);
|
|
|
|
expect(answers).toEqual([undefined]);
|
|
app.unmount();
|
|
}, 10_000);
|
|
|
|
test('a question with no options goes straight to free text', async () => {
|
|
const answers: (string[] | undefined)[] = [];
|
|
const app = render(
|
|
<AskPanel pending={{ req: { question: 'name it', multiple: false }, resolve: (a) => answers.push(a) }} />,
|
|
);
|
|
await wait(120);
|
|
expect(app.lastFrame()).toContain('ype your answer');
|
|
|
|
for (const ch of 'shiro') {
|
|
app.stdin.write(ch);
|
|
await wait(40);
|
|
}
|
|
app.stdin.write('\r');
|
|
await wait(200);
|
|
|
|
expect(answers).toEqual([['shiro']]);
|
|
app.unmount();
|
|
}, 15_000);
|