refactor(skill): scope provider invalidation

This commit is contained in:
Tianyi Cui
2026-07-29 02:06:07 +08:00
parent def1b5c55a
commit 979fa8ab33
24 changed files with 295 additions and 181 deletions

View File

@@ -34,6 +34,10 @@ class MemoryProvider implements SkillProvider {
}
}
function registerProvider(ctx: Context, provider: SkillProvider): () => void {
return ctx.skills.registerProvider(() => provider)
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
@@ -59,8 +63,8 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
const disposeMemory = ctx.skills.registerProvider(provider)
ctx.skills.registerProvider(overrideProvider)
const disposeMemory = registerProvider(ctx, provider)
registerProvider(ctx, overrideProvider)
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
['a-skill', 'A skill', 'memory'],
@@ -84,24 +88,52 @@ describe('SkillService registry', () => {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
ctx.skills.registerProvider(sameRankProvider)
registerProvider(ctx, sameRankProvider)
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
await expect(ctx.plugin({
name: 'duplicate-memory',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(new MemoryProvider([]))
registerProvider(pluginCtx, new MemoryProvider([]))
},
})).rejects.toThrow('already registered')
expect(() => ctx.skills.registerProvider({
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
let rejectedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
rejectedSignal = control.signal
return {
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
}
})).toThrow('reserved')
expect(rejectedSignal?.aborted).toBe(true)
const factoryFailure = new Error('factory failed')
let failedSignal: AbortSignal | undefined
expect(() => ctx.skills.registerProvider((control) => {
failedSignal = control.signal
throw factoryFailure
})).toThrow(factoryFailure)
expect(failedSignal?.reason).toBe(factoryFailure)
const effectContext = new Context()
const effectService = new SkillService(effectContext)
const effectFailure = new Error('effect registration failed')
vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure })
let effectSignal: AbortSignal | undefined
expect(() => effectService.registerProvider((control) => {
effectSignal = control.signal
return {
name: 'effect-provider',
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
}
})).toThrow(effectFailure)
expect(effectSignal?.reason).toBe(effectFailure)
disposeMemory()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
@@ -111,7 +143,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad-candidate',
list: () => Promise.resolve([{
...memorySkill('bad-candidate', 'placeholder', 1),
@@ -125,7 +157,7 @@ describe('SkillService registry', () => {
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
badBoolean.skills.registerProvider({
registerProvider(badBoolean, {
name: 'bad-boolean',
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
@@ -140,7 +172,7 @@ describe('SkillService registry', () => {
it('rejects non-array provider results and every malformed candidate scalar', async () => {
const badList = new Context()
await badList.plugin(SkillService)
badList.skills.registerProvider({
registerProvider(badList, {
name: 'non-array-list',
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
get: () => Promise.resolve(undefined),
@@ -171,7 +203,7 @@ describe('SkillService registry', () => {
path: '/skills/candidate/SKILL.md',
...patch,
} as SkillCandidate
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([candidate]),
get: () => Promise.resolve(undefined),
@@ -195,7 +227,7 @@ describe('SkillService registry', () => {
rank: 1,
locator: 'skill-a',
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'contextual',
async list(received) {
listedWith = received
@@ -218,7 +250,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let getCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'cached',
async list() {
return [{
@@ -267,7 +299,7 @@ describe('SkillService registry', () => {
})
}
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'held',
async list() {
return [{
@@ -347,7 +379,7 @@ describe('SkillService registry', () => {
}
let listCalls = 0
let received: SkillCandidate | undefined
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'detached',
async list() {
listCalls += 1
@@ -422,7 +454,7 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const providerName = `definition-provider-${index}`
const skillName = `definition-${index}`
ctx.skills.registerProvider({
registerProvider(ctx, {
name: providerName,
list: () => Promise.resolve([{
name: skillName,
@@ -455,7 +487,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'bad',
async list() {
return [memorySkill('Bad_Name', 'bad', 1)]
@@ -474,7 +506,7 @@ describe('SkillService registry', () => {
for (const candidate of invalidCandidates) {
const invalid = new Context()
await invalid.plugin(SkillService)
invalid.skills.registerProvider({
registerProvider(invalid, {
name: candidate.name,
async list() {
return [candidate]
@@ -492,7 +524,7 @@ describe('SkillService registry', () => {
it('sorts model-visible summaries without locale-sensitive collation', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider(new MemoryProvider([
registerProvider(ctx, new MemoryProvider([
memorySkill('z-skill', 'Z skill', 10),
memorySkill('a-skill', 'A skill', 10),
]))
@@ -517,7 +549,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
ctx.skills.registerProvider(provider)
registerProvider(ctx, provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
provider.replace([memorySkill('second-skill', 'Second', 10)])
@@ -544,7 +576,7 @@ describe('SkillService registry', () => {
let fail = true
let flakyCalls = 0
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'flaky',
async list() {
flakyCalls += 1
@@ -572,21 +604,27 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
const dispose = ctx.skills.registerProvider(provider)
let invalidate = (): void => {}
let signal: AbortSignal | undefined
const dispose = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
signal = control.signal
return provider
})
expect((await ctx.skills.snapshot()).complete).toBe(true)
provider.replace([memorySkill('second-skill', 'Second', 10)])
ctx.skills.invalidateProvider(new MemoryProvider([]))
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
ctx.skills.invalidateProvider(provider)
invalidate()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
dispose()
expect(signal?.aborted).toBe(true)
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
ctx.skills.registerProvider(replacement)
registerProvider(ctx, replacement)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
ctx.skills.invalidateProvider(provider)
invalidate()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
expect(replacement.listCalls).toBe(1)
})
@@ -598,11 +636,13 @@ describe('SkillService registry', () => {
let changes = 0
ctx.on('skills/change', () => { changes += 1 })
const disposeProvider = ctx.skills.registerProvider(provider)
let invalidate = (): void => {}
const disposeProvider = ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
expect(changes).toBe(1)
ctx.skills.invalidateProvider(new MemoryProvider([]))
expect(changes).toBe(1)
ctx.skills.invalidateProvider(provider)
invalidate()
expect(changes).toBe(2)
const disposeRuntime = ctx.skills.register({
@@ -616,7 +656,7 @@ describe('SkillService registry', () => {
expect(changes).toBe(4)
disposeProvider()
expect(changes).toBe(5)
ctx.skills.invalidateProvider(provider)
invalidate()
expect(changes).toBe(5)
})
@@ -632,7 +672,7 @@ describe('SkillService registry', () => {
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
const provider = new MemoryProvider([])
expect(() => ctx.skills.registerProvider(provider)).not.toThrow()
expect(() => registerProvider(ctx, provider)).not.toThrow()
await Promise.resolve()
expect(observed).toBe(1)
expect(warnings).toEqual([
@@ -662,12 +702,16 @@ describe('SkillService registry', () => {
}
return await originalList(options)
}
ctx.skills.registerProvider(provider)
let invalidate = (): void => {}
ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
const pending = ctx.skills.list()
await started.promise
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
ctx.skills.invalidateProvider(provider)
invalidate()
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
@@ -695,7 +739,7 @@ describe('SkillService registry', () => {
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
},
}
ctx.skills.registerProvider(provider)
registerProvider(ctx, provider)
expect(await ctx.skills.get('old-name')).toBeUndefined()
await ctx.skills.list()
@@ -705,7 +749,7 @@ describe('SkillService registry', () => {
it('returns undefined when a discovered candidate disappears before loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'vanished-body',
async list() {
return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }]
@@ -728,7 +772,7 @@ describe('SkillService registry', () => {
throw new Error('provider failure coercion failed')
},
}
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'hostile-failure',
list() {
// Deliberately violate the provider contract to prove containment is total.
@@ -753,7 +797,7 @@ describe('SkillService registry', () => {
let release: (() => void) | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const dispose = ctx.skills.registerProvider({
const dispose = registerProvider(ctx, {
name: 'delayed',
async list() {
markStarted?.()
@@ -783,7 +827,7 @@ describe('SkillService registry', () => {
const held = new Promise<SkillCandidate[]>((resolve) => {
release = () => { resolve([]) }
})
ctx.skills.registerProvider({
registerProvider(ctx, {
name: 'uncooperative',
list(options) {
seenSignal = options.signal