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

@@ -26,6 +26,7 @@ import {
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
type SkillProviderControl,
type SkillSource,
} from '@deepseek-ai/dsh-skill'
@@ -119,8 +120,11 @@ interface ResolvedWatchConfig {
/** Register the local filesystem skill provider on `ctx.skills`. */
export function apply(ctx: Context, config: Config = {}): void {
const provider = new LocalSkillProvider(ctx, config)
ctx.skills.registerProvider(provider)
let provider!: LocalSkillProvider
ctx.skills.registerProvider((control) => {
provider = new LocalSkillProvider(ctx, control, config)
return provider
})
ctx.effect(function* () {
yield async () => { await provider.dispose() }
}, 'skill-local watcher')
@@ -138,12 +142,18 @@ export class LocalSkillProvider implements SkillProvider {
private readonly customSkillDirs: string[]
private readonly watchManager: SkillWatchManager
private readonly bundledSkillDir: string | undefined
private disposal: Promise<void> | undefined
constructor(private readonly ctx: Context, config: Config = {}) {
constructor(
private readonly ctx: Context,
control: SkillProviderControl,
config: Config = {},
) {
this.dshHome = resolveDshHome(config.dshHome)
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config))
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
}
@@ -197,9 +207,13 @@ export class LocalSkillProvider implements SkillProvider {
this.watchManager.observeHostMutation(path)
}
/** Close every host watcher and contain late filesystem callbacks. */
async dispose(): Promise<void> {
await this.watchManager.dispose()
/**
* Close every host watcher and contain late filesystem callbacks.
* @returns a shared promise that settles when every watcher reaches quiescence.
*/
dispose(): Promise<void> {
this.disposal ??= this.watchManager.dispose()
return this.disposal
}
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
@@ -250,7 +264,7 @@ class SkillWatchManager {
constructor(
private readonly ctx: Context,
private readonly provider: SkillProvider,
private readonly invalidate: () => void,
private readonly config: ResolvedWatchConfig,
) {}
@@ -286,18 +300,17 @@ class SkillWatchManager {
evictedProject = true
}
await Promise.all(pending)
if (evictedProject) this.ctx.skills.invalidateProvider(this.provider)
if (evictedProject) this.invalidate()
}
observeHostMutation(path: string): void {
if (this.closing) return
const normalized = resolve(path)
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
this.ctx.skills.invalidateProvider(this.provider)
this.invalidate()
}
async dispose(): Promise<void> {
if (this.closing) return
this.closing = true
const states = [...this.roots.values()]
this.roots.clear()
@@ -376,6 +389,8 @@ class SkillWatchManager {
}
}
// FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis
// service; keep skill filtering and invalidation here.
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
while (!this.closing && state.owners.size > 0) {
const mode = await resolveRootWatchMode(state.root.path)
@@ -489,7 +504,7 @@ class SkillWatchManager {
queueMicrotask(() => {
this.invalidationQueued = false
if (this.closing) return
this.ctx.skills.invalidateProvider(this.provider)
this.invalidate()
})
}

View File

@@ -123,12 +123,8 @@ describe('skill-local watcher failures', () => {
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill'])
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
let invalidations = 0
ctx.skills.invalidateProvider = (provider) => {
invalidations += 1
invalidateProvider(provider)
}
ctx.on('skills/change', () => { invalidations += 1 })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected a root watcher')
@@ -169,14 +165,17 @@ describe('skill-local watcher failures', () => {
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
ctx.skills.registerProvider(provider)
const discovery = provider.list({})
await settle()
@@ -187,6 +186,7 @@ describe('skill-local watcher failures', () => {
first.emitter.emit('ready')
await Promise.all([discovery, disposal])
disposeProvider()
await settle()
expect(first.closeCalls).toBeGreaterThan(0)
})
@@ -198,14 +198,17 @@ describe('skill-local watcher failures', () => {
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
ctx.skills.registerProvider(provider)
const discovery = provider.list({})
await settle()
@@ -216,5 +219,6 @@ describe('skill-local watcher failures', () => {
await expect(discovery).rejects.toThrow('opening failed during disposal')
await disposal
disposeProvider()
})
})

View File

@@ -572,12 +572,8 @@ describe('LocalSkillProvider', () => {
const root = join(home, '.agents/skills')
const ctx = await setupLocal(home)
expect(await ctx.skills.list()).toEqual([])
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
let invalidations = 0
ctx.skills.invalidateProvider = (provider) => {
invalidations += 1
invalidateProvider(provider)
}
ctx.on('skills/change', () => { invalidations += 1 })
await writeSkill(root, 'observed-skill', 'Observed skill')
const path = join(root, 'observed-skill/SKILL.md')
@@ -657,15 +653,18 @@ describe('LocalSkillProvider', () => {
await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const provider = new SkillLocal.LocalSkillProvider(ctx, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
let provider!: SkillLocal.LocalSkillProvider
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
return provider
})
ctx.skills.registerProvider(provider)
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
await provider.dispose()
@@ -673,6 +672,7 @@ describe('LocalSkillProvider', () => {
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
disposeProvider()
})
it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
@@ -740,7 +740,10 @@ describe('LocalSkillProvider', () => {
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
expect(new SkillLocal.LocalSkillProvider(empty, {
signal: new AbortController().signal,
invalidate() {},
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
README.md: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1
README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195
README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56
README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200

View File

@@ -10,8 +10,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement.
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
@@ -19,7 +18,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Events
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
### Config
@@ -29,13 +28,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
## Provider Contract
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and that exact provider is invalidated so the next snapshot rediscovers its catalog.
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
## Runtime Skills
@@ -55,7 +54,7 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
## Known Limitations and Deferred Work
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism.
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state.
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.

View File

@@ -10,8 +10,7 @@
### 公开 API
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留`ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect可安全用于 HMR精确的 Cordis disposer 持有序组合拆卸。
- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册只读结果。重复提供方名称会抛错,`runtime` 保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false不完整观测绝不缓存使面向模型的消费方可以保留上一份可用目录并在下一个请求边界重试。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
@@ -19,7 +18,7 @@
### 事件
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
### 配置
@@ -29,13 +28,13 @@
## 提供方契约
提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false结果也不会缓存。提供方或运行时修订发生变更时会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序然后按提供方本地顺序解析。摘要按 skill 名称排序。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
## 运行时 Skill
@@ -55,7 +54,7 @@
## 已知限制与待完成工作
- **失效由提供方驱动**:注册表没有 TTL无法推断任意远程来源是否已发生变化每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`
- **失效由提供方驱动**:注册表没有 TTL无法推断任意远程来源是否已发生变化每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。

View File

@@ -117,6 +117,14 @@ export interface SkillProvider {
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
export interface SkillProviderControl {
/** Aborts if registration fails or when the exact provider registration is disposed. */
readonly signal: AbortSignal
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
readonly invalidate: () => void
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalogs kept in memory. */
@@ -180,43 +188,50 @@ export class SkillService extends Service {
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
* the provider and invalidates catalog caches.
* @param provider - the provider to register by `provider.name`.
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(provider: SkillProvider): () => void {
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
const lifecycle = new AbortController()
let active = false
let provider: SkillProvider
const control: SkillProviderControl = {
signal: lifecycle.signal,
invalidate: () => {
if (active) this.invalidateProvider(provider)
},
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
providers.delete(name)
invalidateCache()
try {
provider = create(control)
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* Invalidate catalogs contributed by one currently registered provider. Exact object identity
* prevents a late callback from an old provider instance from invalidating its replacement.
* Calls for an already-unregistered provider are harmless.
* @param provider - exact provider instance whose external source changed.
*/
invalidateProvider(provider: SkillProvider): void {
if (this.providers.get(provider.name)?.provider !== provider) return
this.invalidateCache()
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
active = true
providers.set(name, { provider, order })
invalidateCache()
yield () => {
active = false
providers.delete(name)
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
invalidateCache()
}
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
throw error
}
}
/**
@@ -391,6 +406,11 @@ export class SkillService extends Service {
this.notifyChange()
}
private invalidateProvider(provider: SkillProvider): void {
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
}
/** Notify catalog observers without making their refresh work load-bearing. */
private notifyChange(): void {
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {

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

View File

@@ -153,7 +153,7 @@ describe('dsh-tool-skill', () => {
const home = await tempDir('tool-prefix-signal')
const ctx = await setup(home)
let seenSignal: AbortSignal | undefined
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'signal-probe',
async list(options) {
seenSignal = options.signal
@@ -162,7 +162,7 @@ describe('dsh-tool-skill', () => {
async get() {
return undefined
},
})
}))
const controller = new AbortController()
await composePrefix(ctx, '/workspace', controller.signal)
@@ -245,7 +245,11 @@ describe('dsh-tool-skill', () => {
return undefined
},
}
ctx.skills.registerProvider(provider)
let invalidate = (): void => {}
ctx.skills.registerProvider((control) => {
invalidate = control.invalidate
return provider
})
const session = new Session(SessionId('incomplete-prefix'))
const agent = sessionAgent(session)
openMessageTurn(session)
@@ -253,7 +257,7 @@ describe('dsh-tool-skill', () => {
await composePrefixForAgent(ctx, agent)
expect(catalogMessages(session)).toEqual([])
failing = false
ctx.skills.invalidateProvider(provider)
invalidate()
await fireStep(ctx, agent, 1, 1)
expect(catalogMessages(session)).toEqual([])
@@ -421,7 +425,7 @@ describe('dsh-tool-skill', () => {
openMessageTurn(session)
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
ctx.skills.registerProvider({
ctx.skills.registerProvider(() => ({
name: 'failing',
async list() {
throw new Error('temporarily unavailable')
@@ -429,7 +433,7 @@ describe('dsh-tool-skill', () => {
async get() {
return undefined
},
})
}))
disposeStable()
await fireStep(ctx, agent, 1, 1)