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.
230 lines
6.0 KiB
TypeScript
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);
|