Summarize pruned span: droppedSpan + summarizeDiscarded (6k excerpt, 3-6 lines, one call per compaction) with retained note already landed; flip TODO Now checkboxes to [x]. Hot-reload: Session.updateSkills/updatePlugins with pendingSkills/ pendingHost deferred during a turn, drainPendingHotReload at turn boundary, live skill tool closure, cli.tsx rebuilds host from builtin+registry+external on /registry add/remove. Guard now uses live pluginHost. Test mid-session skill callability and plugin enforcement without restart. Spec: .hermes/plans/todo-now-next.md
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
|
import type { LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
|
import { Session } from '../src/session';
|
|
import { createHost } from '../src/plugins';
|
|
import { guardPlugin } from '../src/plugins-builtin';
|
|
import type { Skill } from '../src/skills';
|
|
|
|
const usage = { inputTokens: { total: 1 }, outputTokens: { total: 1 } } as any;
|
|
const stream = (parts: LanguageModelV4StreamPart[]) => ({
|
|
stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }),
|
|
});
|
|
const text = (body: string): LanguageModelV4StreamPart[] => [
|
|
{ type: 'text-start', id: '0' },
|
|
{ type: 'text-delta', id: '0', delta: body },
|
|
{ type: 'text-end', id: '0' },
|
|
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
|
];
|
|
const toolCall = (id: string, toolName: string, input: unknown): LanguageModelV4StreamPart[] => [
|
|
{ type: 'tool-input-start', id, toolName },
|
|
{ type: 'tool-input-end', id },
|
|
{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) },
|
|
{ type: 'finish', finishReason: { unified: 'tool-calls', raw: 'tool_use' }, usage },
|
|
];
|
|
|
|
function skill(name: string, body = `do ${name}`): Skill {
|
|
return { name, description: `${name} skill`, body, origin: 'registry' };
|
|
}
|
|
|
|
test('a skill installed mid-session is callable next turn without restart', async () => {
|
|
let call = 0;
|
|
const session = new Session({
|
|
model: new MockLanguageModelV4({
|
|
doStream: async (o) => {
|
|
// first turn: whatever prompt, return alpha; second turn: return beta
|
|
const prompt = JSON.stringify(o.prompt);
|
|
// after hot-reload, next send contains "beta"
|
|
if (prompt.includes('beta')) return stream(toolCall('c2', 'skill', { name: 'beta' }));
|
|
// first turn returns alpha on first step, text on second
|
|
if (call++ === 0) return stream(toolCall('c1', 'skill', { name: 'alpha' }));
|
|
return stream(text('done alpha'));
|
|
},
|
|
}),
|
|
askApproval: async () => 'deny',
|
|
skills: [skill('alpha')],
|
|
});
|
|
|
|
// turn 1: alpha exists
|
|
for await (const _ of session.send('use alpha')) void _;
|
|
expect(JSON.stringify(session.messages)).toContain('do alpha');
|
|
|
|
// hot-reload: beta added
|
|
session.updateSkills([skill('alpha'), skill('beta')]);
|
|
|
|
// next turn: beta is now callable — should succeed as tool-result, not error
|
|
call = 0;
|
|
// need a model that will handle the beta tool then text
|
|
let step = 0;
|
|
(session as any).model = new MockLanguageModelV4({
|
|
doStream: async () => {
|
|
if (step++ === 0) return stream(toolCall('c2', 'skill', { name: 'beta' }));
|
|
return stream(text('done beta'));
|
|
},
|
|
});
|
|
const events: string[] = [];
|
|
for await (const ev of session.send('use beta')) events.push(ev.type);
|
|
expect(events).toContain('tool-result');
|
|
expect(events).not.toContain('tool-error');
|
|
expect(JSON.stringify(session.messages)).toContain('do beta');
|
|
});
|
|
|
|
test('hot-reload during a turn is deferred until the turn ends', async () => {
|
|
const session = new Session({
|
|
model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')) }),
|
|
askApproval: async () => 'deny',
|
|
skills: [skill('alpha')],
|
|
});
|
|
|
|
// simulate in-flight turn
|
|
(session as any).controller = new AbortController();
|
|
session.updateSkills([skill('alpha'), skill('beta')]);
|
|
expect((session as any).pendingSkills).toBeDefined();
|
|
expect((session as any).currentSkills.map((s: Skill) => s.name)).toEqual(['alpha']);
|
|
|
|
// drain at turn boundary
|
|
(session as any).controller = undefined;
|
|
(session as any).drainPendingHotReload();
|
|
expect((session as any).currentSkills.map((s: Skill) => s.name)).toEqual(['alpha', 'beta']);
|
|
expect((session as any).pendingSkills).toBeUndefined();
|
|
});
|
|
|
|
test('a plugin installed mid-session is enforced next turn', async () => {
|
|
let n = 0;
|
|
const session = new Session({
|
|
model: new MockLanguageModelV4({
|
|
doStream: async () => (n++ === 0 ? stream(toolCall('c1', 'bash', { command: 'rm -rf /' })) : stream(text('ok'))),
|
|
}),
|
|
askApproval: async () => 'once' as const,
|
|
plugins: createHost([]),
|
|
});
|
|
|
|
// install guard mid-session
|
|
session.updatePlugins(createHost([guardPlugin]));
|
|
const events: string[] = [];
|
|
const notices: string[] = [];
|
|
for await (const ev of session.send('clean')) {
|
|
events.push(ev.type);
|
|
if (ev.type === 'notice') notices.push(ev.text);
|
|
}
|
|
expect(events).toContain('tool-denied');
|
|
expect(notices.join()).toContain('recursive or forced delete');
|
|
});
|