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:
Yichen Jiang
2026-08-08 13:14:49 +08:00
parent 7750789c8e
commit c08fa27e5c
10 changed files with 225 additions and 86 deletions
@@ -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)
})
})