File size: 6,851 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | import { afterEach, describe, expect, it } from 'vitest';
import { InMemorySkillCatalog } from '#/features/skill/catalog/registry';
import { stubSkill } from './catalog/stubs';
import { createTestAgent, skillServices, type TestAgentContext } from '../../harness';
describe('activateSkill', () => {
let ctx: TestAgentContext;
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
function agentWithCommitSkill(): TestAgentContext {
const catalog = new InMemorySkillCatalog();
catalog.register(stubSkill('commit', { content: '# Commit body' }));
return createTestAgent(skillServices(catalog));
}
it('launches a turn with the rendered skill prompt and returns its id', async () => {
ctx = agentWithCommitSkill();
ctx.mockNextResponse({ type: 'text', text: 'committed' });
const launched = await ctx.rpc.activateSkill({ name: 'commit', args: '-m fix' });
expect(launched?.turn_id).toBe(0);
await ctx.untilTurnEnd();
const llmInput = JSON.stringify(ctx.llmInputs());
expect(llmInput).toContain('skill-loaded');
expect(llmInput).toContain('# Commit body');
expect(llmInput).toContain('ARGUMENTS: -m fix');
});
it('rejects for an unknown skill instead of failing silently', async () => {
ctx = agentWithCommitSkill();
await expect(ctx.rpc.activateSkill({ name: 'missing' })).rejects.toThrow(/not found/i);
});
});
describe('promptWithSkills', () => {
let ctx: TestAgentContext;
afterEach(async () => {
try {
await ctx.expectResumeMatches();
} finally {
await ctx.dispose();
}
});
function agentWithSkills(): TestAgentContext {
const catalog = new InMemorySkillCatalog();
catalog.register(stubSkill('review', { content: '# Review body' }));
catalog.register(stubSkill('security', { content: '# Security body' }));
return createTestAgent(skillServices(catalog));
}
it('bundles every skill into the prompt message and launches exactly one turn', async () => {
ctx = agentWithSkills();
ctx.mockNextResponse({ type: 'text', text: 'done' });
const launched = await ctx.rpc.promptWithSkills({
input: [{ type: 'text', text: 'Review this change.' }],
skills: [{ name: 'review' }, { name: 'security' }],
});
expect(launched.turn_id).toBe(0);
expect(launched.prompt_id).toBeTruthy();
expect(launched.state).toBe('running');
await ctx.untilTurnEnd();
expect(ctx.llmCalls).toHaveLength(1);
const llmInput = JSON.stringify(ctx.llmInputs());
expect(llmInput).toContain('# Review body');
expect(llmInput).toContain('# Security body');
expect(llmInput).toContain('Review this change.');
const messages = ctx.context.get();
const promptMessage = messages.find((message) => message.origin?.kind === 'user');
expect(messages.filter((message) => message.origin?.kind === 'skill_activation')).toHaveLength(
0,
);
expect(promptMessage?.origin).toMatchObject({
kind: 'user',
skillActivations: [{ skillName: 'review' }, { skillName: 'security' }],
});
const texts = promptMessage?.content
.filter((part) => part.type === 'text')
.map((part) => part.text);
expect(texts?.[0]).toContain('# Review body');
expect(texts?.[1]).toContain('# Security body');
expect(texts?.[2]).toContain('Review this change.');
const events = ctx.allEvents.filter(
(event) =>
event.type === '[rpc]' &&
(event.event === 'skill.activated' || event.event === 'turn.started'),
);
expect(events.map((event) => event.event)).toEqual([
'skill.activated',
'skill.activated',
'turn.started',
]);
expect(
events
.slice(0, 2)
.map((event) => (event.args as { readonly skillName?: string }).skillName),
).toEqual(['review', 'security']);
const started = events[2]?.args as { readonly prompt?: string };
expect(started.prompt).toBe('Review this change.');
});
it('rejects the whole submission when any skill is unknown', async () => {
ctx = agentWithSkills();
await expect(
ctx.rpc.promptWithSkills({
input: [{ type: 'text', text: 'Review this change.' }],
skills: [{ name: 'review' }, { name: 'missing' }],
}),
).rejects.toThrow(/not found/i);
expect(ctx.llmCalls).toHaveLength(0);
expect(ctx.context.get()).toHaveLength(0);
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
).toBe(false);
});
it('rejects a grouped submission with an empty prompt message', async () => {
ctx = agentWithSkills();
await expect(
ctx.rpc.promptWithSkills({
input: [],
skills: [{ name: 'review' }],
}),
).rejects.toThrow(/non-empty prompt/i);
expect(ctx.llmCalls).toHaveLength(0);
expect(ctx.context.get()).toHaveLength(0);
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
).toBe(false);
});
it('rejects a grouped submission without any skills', async () => {
ctx = agentWithSkills();
await expect(
ctx.rpc.promptWithSkills({
input: [{ type: 'text', text: 'Review this change.' }],
skills: [],
}),
).rejects.toThrow(/at least one skill/i);
expect(ctx.llmCalls).toHaveLength(0);
expect(ctx.context.get()).toHaveLength(0);
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'skill.activated'),
).toBe(false);
});
it('undoes the bundled prompt as a single anchor', async () => {
ctx = agentWithSkills();
await ctx.restorePersisted();
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.promptWithSkills({
input: [{ type: 'text', text: 'Review this change.' }],
skills: [{ name: 'review' }, { name: 'security' }],
});
await ctx.untilTurnEnd();
expect(ctx.context.get().length).toBeGreaterThan(0);
const undone = await ctx.rpc.undoHistory({ count: 1 });
expect(undone).toBe(1);
expect(ctx.context.get()).toHaveLength(0);
});
it('accepts later prompt_id reuse of a bundled prompt id as a pure correlation id', async () => {
ctx = agentWithSkills();
ctx.mockNextResponse({ type: 'text', text: 'done' });
const launched = await ctx.rpc.promptWithSkills({
input: [{ type: 'text', text: 'Review this change.' }],
skills: [{ name: 'review' }],
});
await ctx.untilTurnEnd();
ctx.mockNextResponse({ type: 'text', text: 'again done' });
const reused = await ctx.rpc.prompt({
input: [{ type: 'text', text: 'again' }],
promptId: launched.prompt_id,
});
expect(reused).toEqual({ turn_id: 1 });
await ctx.untilTurnEnd();
});
});
|