Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
248 lines
8.0 KiB
TypeScript
248 lines
8.0 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, 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();
|
|
});
|
|
|
|
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);
|