Initial commit: shiro-neko 0.1.0-beta.1
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.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { render } from 'ink-testing-library';
|
||||
import React from 'react';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import { Session } from '../src/session';
|
||||
import { App, createApprovalBridge, type AppHooks } from '../src/ui/App';
|
||||
import { testHooks } from './helpers';
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 2, noCache: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 1 },
|
||||
} as any;
|
||||
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
({
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'answer' },
|
||||
{ type: 'text-end', id: '0' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
],
|
||||
chunkDelayInMs: null,
|
||||
initialDelayInMs: null,
|
||||
}),
|
||||
}) as any,
|
||||
});
|
||||
|
||||
function mount(over: Partial<AppHooks> = {}) {
|
||||
const bridge = createApprovalBridge();
|
||||
const session = new Session({ model, askApproval: bridge.ask });
|
||||
const app = render(<App session={session} bridge={bridge} header="hdr" hooks={testHooks(over)} />);
|
||||
return { app, session };
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const DOWN = '\u001B[B';
|
||||
const UP = '\u001B[A';
|
||||
|
||||
async function press(app: ReturnType<typeof render>, s: string, ms = 130) {
|
||||
app.stdin.write(s);
|
||||
await wait(ms);
|
||||
}
|
||||
|
||||
test('typing a slash opens the menu listing every command', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
expect(app.lastFrame()).not.toContain('/compact');
|
||||
|
||||
await press(app, '/');
|
||||
const frame = app.lastFrame() ?? '';
|
||||
expect(frame).toContain('/help');
|
||||
expect(frame).toContain('/provider');
|
||||
expect(frame).toContain('/compact');
|
||||
expect(frame).toContain('tab complete');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('the menu narrows as more characters are typed', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
await press(app, '/');
|
||||
await press(app, 'c');
|
||||
await press(app, 'o');
|
||||
|
||||
const frame = app.lastFrame() ?? '';
|
||||
expect(frame).toContain('/compact');
|
||||
expect(frame).toContain('/cost');
|
||||
expect(frame).not.toContain('/provider');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('tab completes the highlighted entry into the input', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
await press(app, '/');
|
||||
await press(app, 'c');
|
||||
await press(app, 'o');
|
||||
await press(app, 's');
|
||||
await press(app, '\t');
|
||||
|
||||
const frame = app.lastFrame() ?? '';
|
||||
expect(frame).toContain('> /cost');
|
||||
expect(frame).not.toContain('tab complete');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('up and down move the highlight and wrap around', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
await press(app, '/');
|
||||
await press(app, 'm');
|
||||
await press(app, 'o');
|
||||
await press(app, UP);
|
||||
await press(app, '\t');
|
||||
|
||||
expect(app.lastFrame()).toContain('> /model');
|
||||
expect(app.lastFrame()).not.toContain('/models ');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('enter on an open menu runs the highlighted command, not the raw text', async () => {
|
||||
const seen: string[] = [];
|
||||
const { app } = mount({
|
||||
listModels: async () => {
|
||||
seen.push('listModels');
|
||||
return { models: [] };
|
||||
},
|
||||
});
|
||||
await wait(150);
|
||||
await press(app, '/');
|
||||
await press(app, 'm');
|
||||
await press(app, 'o');
|
||||
await press(app, '\r', 500);
|
||||
|
||||
expect(seen).toEqual(['listModels']);
|
||||
expect(app.lastFrame()).toContain('> /models');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('esc dismisses the menu and leaves the typed text alone', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
await press(app, '/');
|
||||
await press(app, 'c');
|
||||
expect(app.lastFrame()).toContain('/compact');
|
||||
|
||||
await press(app, '\u001B', 200);
|
||||
const frame = app.lastFrame() ?? '';
|
||||
expect(frame).not.toContain('tab complete');
|
||||
expect(frame).toContain('> /c');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('an unknown slash word shows no menu and reports on submit', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
for (const ch of '/zzz') await press(app, ch, 90);
|
||||
expect(app.lastFrame()).not.toContain('tab complete');
|
||||
|
||||
await press(app, '\r', 400);
|
||||
expect(app.lastFrame()).toContain('unknown command /zzz');
|
||||
|
||||
app.unmount();
|
||||
}, 10_000);
|
||||
|
||||
test('the menu closes once an argument is being typed', async () => {
|
||||
const { app } = mount();
|
||||
await wait(150);
|
||||
for (const ch of '/resume') await press(app, ch, 70);
|
||||
expect(app.lastFrame()).toContain('/resume <id>');
|
||||
|
||||
await press(app, ' ', 150);
|
||||
expect(app.lastFrame()).not.toContain('tab complete');
|
||||
|
||||
app.unmount();
|
||||
}, 15_000);
|
||||
|
||||
test('plain prose never triggers the menu and still reaches the model', async () => {
|
||||
const { app, session } = mount();
|
||||
await wait(150);
|
||||
for (const ch of 'hello') await press(app, ch, 60);
|
||||
expect(app.lastFrame()).not.toContain('tab complete');
|
||||
|
||||
await press(app, '\r', 500);
|
||||
expect(app.lastFrame()).toContain('answer');
|
||||
expect(session.messages[0]?.content).toBe('hello');
|
||||
|
||||
app.unmount();
|
||||
}, 15_000);
|
||||
Reference in New Issue
Block a user