The compaction bug, which is the important one:
beta.2 taught the pruner to drop any assistant part whose reasoning item it had
removed. That was right about the 400 and wrong about everything else. On a
reasoning model every tool call carries a provider itemId, so past the threshold
the model could no longer see what it had already run, and re-ran the same tools
until maxSteps ended the turn. Reproduced at 12 model calls for a job needing 4,
with nothing but the user message reaching the wire.
The dependency is not the part, it is the itemId. A part carrying one is
serialised as `{ type: 'item_reference', id }`, a pointer to an item stored
provider-side that depends on its reasoning item. Without the itemId the same
content goes out inline and carries no dependency at all. Verified against the
provider's own serialiser: `text` with an itemId becomes item_reference, the
identical part without one becomes output_text.
So `dropOrphanedItems` becomes `detachOrphanedItems`: strip the itemId, keep the
content. Compaction may shorten the history; it must not blank it. The new test
asserts behaviour rather than shape — the loop must end because the model chose
to, and every call after the first must still carry the earlier exchange. A shape
assertion passed the whole time the model was losing its memory.
Registry, via `/registry [list|search|add|remove|installed]`:
Skills and plugins are treated differently on purpose. A skill is prompt text, so
installing one puts a stranger's words into the system prompt of every future
session in this project; the install shows the body first and the origin is
recorded, so /skills always says where an instruction came from. A plugin is a
JSON manifest of deny rules, evaluated by compiled code identical for every
install. Loading TypeScript from a URL is declined outright: a plugin that can
block tool calls could otherwise lie about blocking them.
Validated before anything is written: https only (file: and data: rejected), name
matched against ^[a-z0-9][a-z0-9-]*$ so it cannot escape its directory, size
caps on index and body, every regex compiled, pattern length capped since it runs
on every tool call, and the body's own name checked against the index. Installed
skills rank below your own, so an install can never shadow a skill you wrote.
Interface:
- Context is a percentage of the compaction threshold, amber from two thirds and
red at 90. A turn about to lose history now says so beforehand.
- Aligned command menu and registry tables; /skills and /plugins name origins.
538 tests, up from 488. The registry is tested against a real local HTTP server,
and the guard is proven to refuse a .env write end to end rather than assumed to.
267 lines
8.0 KiB
TypeScript
267 lines
8.0 KiB
TypeScript
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 { InstallPrompt, RegistryPanel, type RegistryRow } from '../src/ui/Panels';
|
|
import { testHooks } from './helpers';
|
|
|
|
const usage = { inputTokens: { total: 3, noCache: 3, 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: 'reply' },
|
|
{ type: 'text-end', id: '0' },
|
|
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
|
],
|
|
chunkDelayInMs: null,
|
|
initialDelayInMs: null,
|
|
}),
|
|
}) as any,
|
|
});
|
|
|
|
const rows: RegistryRow[] = [
|
|
{ name: 'migration', kind: 'skill', description: 'Write a database migration' },
|
|
{ name: 'no-secrets', kind: 'plugin', description: 'Refuses credential writes', installed: true },
|
|
];
|
|
|
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
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 };
|
|
}
|
|
|
|
async function run(app: ReturnType<typeof render>, command: string, settle = 400) {
|
|
for (const ch of command) {
|
|
app.stdin.write(ch);
|
|
await wait(30);
|
|
}
|
|
app.stdin.write('\r');
|
|
await wait(settle);
|
|
}
|
|
|
|
test('the registry panel marks skill and plugin differently and flags what is installed', () => {
|
|
const app = render(<RegistryPanel rows={rows} hint="2 available" />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('migration');
|
|
expect(frame).toContain('no-secrets');
|
|
expect(frame).toContain('installed');
|
|
expect(frame).toContain('S skill P plugin');
|
|
app.unmount();
|
|
});
|
|
|
|
test('an empty registry panel says so rather than rendering an empty box', () => {
|
|
const app = render(<RegistryPanel rows={[]} />);
|
|
expect(app.lastFrame()).toContain('nothing found');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the install prompt shows the body and says what a skill actually is', () => {
|
|
const app = render(
|
|
<InstallPrompt
|
|
name="migration"
|
|
kind="skill"
|
|
url="https://example.com/m.md"
|
|
preview={'Migrations live in db/migrations.'}
|
|
/>,
|
|
);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('install skill "migration"?');
|
|
expect(frame).toContain('https://example.com/m.md');
|
|
expect(frame).toContain('Migrations live in db/migrations.');
|
|
expect(frame).toContain('joins your system prompt');
|
|
app.unmount();
|
|
});
|
|
|
|
test('the install prompt says a plugin is data, not code', () => {
|
|
const app = render(
|
|
<InstallPrompt name="no-secrets" kind="plugin" url="https://e.com/p.json" preview="- denies write_file" />,
|
|
);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('nothing here is executed');
|
|
expect(frame).toContain('denies write_file');
|
|
app.unmount();
|
|
});
|
|
|
|
test('a long body is truncated with a count rather than flooding the screen', () => {
|
|
const preview = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n');
|
|
const app = render(<InstallPrompt name="x" kind="skill" url="https://e.com/x.md" preview={preview} lines={5} />);
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('line 4');
|
|
expect(frame).not.toContain('line 20');
|
|
expect(frame).toContain('35 more lines');
|
|
app.unmount();
|
|
});
|
|
|
|
test('/registry lists the index', async () => {
|
|
const { app } = mount({ registry: { ...testHooks().registry, list: async () => rows } });
|
|
await wait(150);
|
|
|
|
await run(app, '/registry');
|
|
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('migration');
|
|
expect(frame).toContain('2 of 2 available');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('/registry search narrows the list', async () => {
|
|
const { app } = mount({ registry: { ...testHooks().registry, list: async () => rows } });
|
|
await wait(150);
|
|
|
|
await run(app, '/registry search migra');
|
|
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('migration');
|
|
expect(frame).not.toContain('no-secrets');
|
|
expect(frame).toContain('1 of 2');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('a failing index surfaces the error instead of an empty panel', async () => {
|
|
const { app } = mount({
|
|
registry: {
|
|
...testHooks().registry,
|
|
list: async () => {
|
|
throw new Error('the registry index is not valid JSON');
|
|
},
|
|
},
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry');
|
|
expect(app.lastFrame()).toContain('not valid JSON');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('/registry add stages, shows the body, and installs only on y', async () => {
|
|
const installed: string[] = [];
|
|
const { app } = mount({
|
|
registry: {
|
|
...testHooks().registry,
|
|
stage: async (name) => ({
|
|
row: { name, kind: 'skill', description: 'd' },
|
|
url: 'https://example.com/m.md',
|
|
preview: 'Migrations live in db/migrations.',
|
|
}),
|
|
install: async (name) => {
|
|
installed.push(name);
|
|
return `installed skill ${name}`;
|
|
},
|
|
},
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry add migration');
|
|
expect(app.lastFrame()).toContain('install skill "migration"?');
|
|
expect(installed).toEqual([]);
|
|
|
|
app.stdin.write('y');
|
|
await wait(400);
|
|
|
|
expect(installed).toEqual(['migration']);
|
|
expect(app.lastFrame()).toContain('installed skill migration');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('n cancels the install and nothing is written', async () => {
|
|
const installed: string[] = [];
|
|
const { app } = mount({
|
|
registry: {
|
|
...testHooks().registry,
|
|
stage: async (name) => ({
|
|
row: { name, kind: 'skill', description: 'd' },
|
|
url: 'https://example.com/m.md',
|
|
preview: 'body',
|
|
}),
|
|
install: async (name) => {
|
|
installed.push(name);
|
|
return 'installed';
|
|
},
|
|
},
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry add migration');
|
|
app.stdin.write('n');
|
|
await wait(400);
|
|
|
|
expect(installed).toEqual([]);
|
|
expect(app.lastFrame()).toContain('install cancelled: migration');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('a staging failure never reaches the confirmation prompt', async () => {
|
|
const { app } = mount({
|
|
registry: {
|
|
...testHooks().registry,
|
|
stage: async () => {
|
|
throw new Error('no registry entry named "nope"');
|
|
},
|
|
},
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry add nope');
|
|
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('no registry entry named');
|
|
expect(frame).not.toContain('install skill');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('/registry remove reports what it removed', async () => {
|
|
const { app } = mount({
|
|
registry: { ...testHooks().registry, remove: async (name) => `removed skill ${name}` },
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry remove migration');
|
|
expect(app.lastFrame()).toContain('removed skill migration');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('/registry installed shows what is already here', async () => {
|
|
const { app } = mount({
|
|
registry: { ...testHooks().registry, installed: async () => [rows[1]!] },
|
|
});
|
|
await wait(150);
|
|
|
|
await run(app, '/registry installed');
|
|
|
|
const frame = app.lastFrame() ?? '';
|
|
expect(frame).toContain('no-secrets');
|
|
expect(frame).toContain('1 from the registry');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|
|
|
|
test('esc dismisses the registry panel', async () => {
|
|
const { app } = mount({ registry: { ...testHooks().registry, list: async () => rows } });
|
|
await wait(150);
|
|
|
|
await run(app, '/registry');
|
|
expect(app.lastFrame()).toContain('migration');
|
|
|
|
app.stdin.write('\u001B');
|
|
await wait(300);
|
|
expect(app.lastFrame()).not.toContain('S skill P plugin');
|
|
|
|
app.unmount();
|
|
}, 20_000);
|