feat(skill): add invocation controls

This commit is contained in:
Yichen Jiang
2026-07-28 17:22:41 +08:00
parent 2a46685414
commit e133e4bddb
49 changed files with 646 additions and 157 deletions

View File

@@ -1,6 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SkillService, {
isModelInvocable,
isUserInvocable,
type SkillCandidate,
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
} from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
@@ -107,6 +114,32 @@ describe('SkillService registry', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('returns an invocation-neutral catalog and resolves model and user policy independently', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const registrations = [
{ name: 'both', invocation: undefined },
{ name: 'model-only', invocation: { userInvocable: false } },
{ name: 'user-only', invocation: { disableModelInvocation: true } },
{ name: 'trusted-only', invocation: { disableModelInvocation: true, userInvocable: false } },
] as const
for (const registration of registrations) {
ctx.skills.register({
name: registration.name,
description: registration.name,
source: 'runtime',
...registration.invocation === undefined ? {} : { invocation: registration.invocation },
content: `${registration.name} body.`,
})
}
const listed = await ctx.skills.list()
expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only'])
expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only'])
expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only'])
expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' })
})
it('validates parsed candidate fields', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
@@ -117,7 +150,7 @@ describe('SkillService registry', () => {
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
disableModelInvocation: 'false' as unknown as boolean,
invocation: { disableModelInvocation: 'false' as unknown as boolean },
}]),
get: () => Promise.resolve(undefined),
})
@@ -130,11 +163,11 @@ describe('SkillService registry', () => {
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
disableModelInvocation: 'false' as unknown as boolean,
invocation: { disableModelInvocation: 'false' as unknown as boolean },
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.disableModelInvocation')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
@@ -163,7 +196,7 @@ describe('SkillService registry', () => {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
disableModelInvocation: false,
invocation: { disableModelInvocation: false, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,
@@ -320,11 +353,12 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const invocation = { disableModelInvocation: false, userInvocable: true }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
@@ -337,7 +371,7 @@ describe('SkillService registry', () => {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'definition resources' },
@@ -366,6 +400,7 @@ describe('SkillService registry', () => {
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
@@ -379,11 +414,12 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
const invocation = { disableModelInvocation: false, userInvocable: true }
const registration = {
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
disableModelInvocation: false,
invocation,
source: 'runtime',
resourceBase,
metadata,
@@ -399,6 +435,7 @@ describe('SkillService registry', () => {
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toBe(resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(loaded?.resourceBase).toBe(resourceBase)
expect(loaded?.metadata).toBe(metadata)
expect(loaded?.provider).toBe('runtime')
@@ -410,7 +447,15 @@ describe('SkillService registry', () => {
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
{ patch: { invocation: null as never }, expected: 'non-object invocation policy' },
{
patch: { invocation: { disableModelInvocation: 'false' as unknown as boolean } },
expected: 'invocation.disableModelInvocation',
},
{
patch: { invocation: { userInvocable: 'true' as unknown as boolean } },
expected: 'invocation.userInvocable',
},
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
@@ -436,7 +481,7 @@ describe('SkillService registry', () => {
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
disableModelInvocation: false,
invocation: { disableModelInvocation: false, userInvocable: true },
provider: providerName,
source: 'test',
content: 'Definition body.',
@@ -668,6 +713,13 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
expect(() => ctx.skills.register({
name: 'bad-invocation',
description: 'Bad invocation',
source: 'runtime',
invocation: [] as never,
content: 'bad',
})).toThrow('non-object invocation policy')
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()