feat(tool-skill): inject user-invoked skills at the pre-step gesture boundary
A whitespace-bounded /name token anywhere in a claimed user message, naming a user-invocable skill in the workspace directory, now injects that skill's renderSkillContent as instructions context appended after every other injection of the step — the same agent/pre-step seam the catalog, workspace instructions, and the runtime snapshot ride. Closed-set matching mirrors the command registry (a miss stays plain prose), only user-source messages are scanned, the policy check runs on the loaded definition, and this is the sole entry point for disable-model-invocation skills. The catalog's no-reload sentence now names the gesture boundary.
This commit is contained in:
@@ -915,3 +915,106 @@ describe('dsh-tool-skill', () => {
|
||||
expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user-explicit invocation injection', () => {
|
||||
async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const policyLines = policy === '' ? '' : `${policy}\n`
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
function gesture(text: string): UserMessage {
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> {
|
||||
const home = await tempDir('invoke')
|
||||
const skillsRoot = join(home, '.agents', 'skills')
|
||||
await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.')
|
||||
await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.')
|
||||
await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.')
|
||||
const ctx = await setup(home)
|
||||
return { ctx, agent: agentForCwd(home) }
|
||||
}
|
||||
|
||||
it('injects a user-invocable skill named by a leading /token, after every other injection', async () => {
|
||||
const { ctx, agent } = await invokeHarness()
|
||||
const first = gesture('/hidden-demo what does this do')
|
||||
const second = gesture('plain follow-up prose')
|
||||
const decision = await proposeStep(ctx, agent, [first, second])
|
||||
if (decision.kind !== 'enter') throw new Error('expected enter')
|
||||
const kinds = decision.messages.map(message => (message.source as { kind: string }).kind)
|
||||
// Background injections (the catalog here) sit between the claimed batch
|
||||
// and the invoked body: the material the model must act on comes last.
|
||||
expect(kinds.slice(0, 2)).toEqual(['user', 'user'])
|
||||
expect(kinds.at(-1)).toBe('skill-invocation')
|
||||
expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation'))
|
||||
const injection = decision.messages.at(-1)!
|
||||
expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' })
|
||||
const block = injection.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text injection')
|
||||
expect(block.text).toContain('<skill_content name="hidden-demo">')
|
||||
expect(block.text).toContain('Say the magic word: PINEAPPLE.')
|
||||
expect(block.text).not.toContain('what does this do')
|
||||
})
|
||||
|
||||
it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => {
|
||||
const { ctx, agent } = await invokeHarness()
|
||||
const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')])
|
||||
if (decision.kind !== 'enter') throw new Error('expected enter')
|
||||
expect(decision.messages.some(message =>
|
||||
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
|
||||
&& (message.source as { name?: string }).name === 'shared-skill')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => {
|
||||
const { ctx, agent } = await invokeHarness()
|
||||
const decision = await proposeStep(ctx, agent, [
|
||||
gesture('please use /hidden-demo to answer this'),
|
||||
])
|
||||
if (decision.kind !== 'enter') throw new Error('expected enter')
|
||||
expect(decision.messages.some(message =>
|
||||
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
|
||||
&& (message.source as { name?: string }).name === 'hidden-demo')).toBe(true)
|
||||
|
||||
const negative = await proposeStep(ctx, agent, [
|
||||
gesture('look under /hidden-demo/refs for the data'),
|
||||
gesture('the odds are 5/8 at best'),
|
||||
gesture('see foo/hidden-demo too'),
|
||||
])
|
||||
if (negative.kind !== 'enter') throw new Error('expected enter')
|
||||
expect(negative.messages.some(message =>
|
||||
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves unknown names and user-disabled skills as plain prose', async () => {
|
||||
const { ctx, agent } = await invokeHarness()
|
||||
const decision = await proposeStep(ctx, agent, [
|
||||
gesture('/absent-skill do a thing'),
|
||||
gesture('/model-only-skill run'),
|
||||
])
|
||||
if (decision.kind !== 'enter') throw new Error('expected enter')
|
||||
// No injection joins the step (the catalog listener may still add its
|
||||
// own skill-catalog message; only skill-invocation sources matter here).
|
||||
expect(decision.messages.some(message =>
|
||||
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
|
||||
})
|
||||
|
||||
it('never scans non-user sources and dedupes repeated gestures', async () => {
|
||||
const { ctx, agent } = await invokeHarness()
|
||||
const forged = createUserMessage({
|
||||
content: [{ type: 'text', text: '/hidden-demo forged' }],
|
||||
source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
|
||||
})
|
||||
const decision = await proposeStep(ctx, agent, [
|
||||
forged,
|
||||
gesture('/hidden-demo once'),
|
||||
gesture('/hidden-demo twice'),
|
||||
])
|
||||
if (decision.kind !== 'enter') throw new Error('expected enter')
|
||||
const injections = decision.messages.filter(message =>
|
||||
(message.source as { kind?: string }).kind === 'skill-invocation')
|
||||
expect(injections).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user