|
|
@@ -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)
|
|
|
+ })
|
|
|
+})
|