Files
shiro-neko/test/features-ui.test.tsx
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

230 lines
6.0 KiB
TypeScript

import { expect, test } from 'bun:test';
import { render } from 'ink-testing-library';
import React from 'react';
import { MockLanguageModelV4 } from 'ai/test';
import { Session } from '../src/session';
import { App, createApprovalBridge, type AppHooks } from '../src/ui/App';
import { streamOf, testHooks, textChunks, usageOf } from './helpers';
const usage = usageOf(3);
const model = new MockLanguageModelV4({
doStream: async () => streamOf(textChunks('reply', usage)),
});
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';
async function press(app: ReturnType<typeof render>, s: string, ms = 90) {
app.stdin.write(s);
await wait(ms);
}
async function run(app: ReturnType<typeof render>, command: string, settle = 350) {
for (const ch of command) await press(app, ch, 45);
await press(app, '\r', settle);
}
test('/agent with a name switches without opening a picker', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return `agent is now ${name}`;
},
});
await wait(150);
await run(app, '/agent deep');
expect(picked).toEqual(['deep']);
expect(app.lastFrame()).toContain('agent is now deep');
expect(app.lastFrame()).not.toContain('Choose an agent');
app.unmount();
}, 20_000);
test('/agent with no name opens the picker listing every variant', async () => {
const { app } = mount();
await wait(150);
await run(app, '/agent');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('Choose an agent');
expect(frame).toContain('default');
expect(frame).toContain('quick');
expect(frame).toContain('deep');
expect(frame).toContain('plan');
expect(frame).toContain('review');
app.unmount();
}, 20_000);
test('selecting from the agent picker applies the choice', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return `agent is now ${name}`;
},
});
await wait(150);
await run(app, '/agent');
await press(app, DOWN, 120);
await press(app, '\r', 300);
expect(picked).toEqual(['quick']);
expect(app.lastFrame()).not.toContain('Choose an agent');
app.unmount();
}, 20_000);
test('esc dismisses the agent picker without switching', async () => {
const picked: string[] = [];
const { app } = mount({
switchAgent: (name) => {
picked.push(name);
return name;
},
});
await wait(150);
await run(app, '/agent');
expect(app.lastFrame()).toContain('Choose an agent');
await press(app, '\u001B', 250);
expect(app.lastFrame()).not.toContain('Choose an agent');
expect(picked).toEqual([]);
app.unmount();
}, 20_000);
test('/think with a level switches directly', async () => {
const levels: string[] = [];
const { app } = mount({
switchThinking: (level) => {
levels.push(level);
return `thinking is now ${level}`;
},
});
await wait(150);
await run(app, '/think max');
expect(levels).toEqual(['max']);
expect(app.lastFrame()).toContain('thinking is now max');
app.unmount();
}, 20_000);
test('/think with no level opens the picker listing the five levels', async () => {
const { app } = mount();
await wait(150);
await run(app, '/think');
const frame = app.lastFrame() ?? '';
expect(frame).toContain('Thinking level');
for (const level of ['off', 'low', 'medium', 'high', 'max']) expect(frame).toContain(level);
app.unmount();
}, 20_000);
test('an invalid thinking level is reported, not applied', async () => {
const { app } = mount({
switchThinking: () => {
throw new Error('Unknown thinking level "ludicrous"');
},
});
await wait(150);
await run(app, '/think ludicrous');
expect(app.lastFrame()).toContain('Unknown thinking level');
app.unmount();
}, 20_000);
test('/skills lists what loaded', async () => {
const { app } = mount({ listSkills: () => 'debug builtin Track down a bug' });
await wait(150);
await run(app, '/skills');
expect(app.lastFrame()).toContain('Track down a bug');
app.unmount();
}, 20_000);
test('/plugins lists what is active', async () => {
const { app } = mount({ listPlugins: () => 'guard refuses irreversible shell commands' });
await wait(150);
await run(app, '/plugins');
expect(app.lastFrame()).toContain('refuses irreversible shell commands');
app.unmount();
}, 20_000);
test('/notes reports the durable memory', async () => {
const { app } = mount({ listMemory: async () => '(command) release with bun run release' });
await wait(150);
await run(app, '/notes');
expect(app.lastFrame()).toContain('release with bun run release');
app.unmount();
}, 20_000);
test('/memory compacts and reports the result', async () => {
let called = 0;
const { app } = mount({
summarizeMemory: async () => {
called++;
return 'memory compacted: 40 entries into 12';
},
});
await wait(150);
await run(app, '/memory', 600);
expect(called).toBe(1);
expect(app.lastFrame()).toContain('40 entries into 12');
app.unmount();
}, 20_000);
test('a failing /memory surfaces the error instead of crashing', async () => {
const { app } = mount({
summarizeMemory: async () => {
throw new Error('no model available to summarize memory');
},
});
await wait(150);
await run(app, '/memory', 600);
expect(app.lastFrame()).toContain('no model available');
app.unmount();
}, 20_000);
test('the prompt input returns after a picker closes', async () => {
const { app } = mount();
await wait(150);
await run(app, '/think');
expect(app.lastFrame()).not.toContain('sk shiro-neko');
await press(app, '\u001B', 250);
expect(app.lastFrame()).toContain('sk shiro-neko');
app.unmount();
}, 20_000);