feat(skill): hot-refresh skill catalogs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 639616d0b75f960e9ccd48546d44db841372bbe2
|
||||
README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
|
||||
README.md: f4f933576c60c8d750e3bd8eb0183ccb4b37e2da
|
||||
README.zh.md: baa0d0ca43a8da3e3177d5c05437501a1fbe1735
|
||||
|
||||
@@ -11,10 +11,16 @@ 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.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.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
|
||||
### 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.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
@@ -27,7 +33,9 @@ A provider registers synchronously and performs remote setup, authentication, an
|
||||
|
||||
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 `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
|
||||
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.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
@@ -39,15 +47,15 @@ The registry does not render model guidance or register model-facing tools. [`@d
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results.
|
||||
Indirectly, through `dsh-tool-skill`, which renders provider summaries into the initial session prefix or durable replacement catalog messages and loaded instructions into retained tool results.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
No direct prompt effect. The named consumer owns initial prefix composition and append-only catalog replacements after invalidation.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload.
|
||||
- **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.
|
||||
- **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.
|
||||
- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract.
|
||||
- **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.
|
||||
|
||||
@@ -11,10 +11,16 @@
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。
|
||||
- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
|
||||
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
|
||||
### 事件
|
||||
|
||||
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
|
||||
|
||||
### 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
@@ -27,7 +33,9 @@
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
|
||||
|
||||
契约违反会快速失败。被拒绝的 `list()` 视为瞬时来源失败:系统记录它、跳过它,并且不缓存。只缓存已完成目录;提供方或运行时修订变更会丢弃正在进行的结果并重试。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
|
||||
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。
|
||||
|
||||
## 运行时 Skill
|
||||
|
||||
@@ -39,15 +47,15 @@
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。
|
||||
通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到初始会话前缀或持久的替换目录消息中,并将已加载指令渲染到已保留工具结果中。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。
|
||||
不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- **已完成目录没有 TTL 或 watcher 失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存 cwd 会保持陈旧,直到被驱逐或重新加载提供方/运行时。
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。
|
||||
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
|
||||
- **提供方列表失败会移除该请求的整个来源**:注册表会记录并跳过它,不提供模型可见诊断或部分目录恢复契约。
|
||||
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
|
||||
|
||||
@@ -87,6 +87,14 @@ export interface SkillLookupOptions {
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** One catalog observation plus whether every registered provider completed discovery. */
|
||||
export interface SkillCatalogSnapshot {
|
||||
/** Sorted model-invocable summaries from providers that completed. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed discovery for this observation. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
export interface SkillProvider {
|
||||
/** Unique provider name in the `ctx.skills` registry. */
|
||||
@@ -119,6 +127,17 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
skills: SkillService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A skill provider, runtime contribution, or provider-backed catalog may
|
||||
* have changed. This is an unfiltered invalidation notification; consumers
|
||||
* refetch the catalog for their own lookup options. Listener failures are
|
||||
* contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'skills/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
interface IndexedCandidate {
|
||||
@@ -189,6 +208,17 @@ export class SkillService extends Service {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
|
||||
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
|
||||
@@ -228,11 +258,26 @@ export class SkillService extends Service {
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary)
|
||||
return (await this.snapshot(options)).skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the current model-invocable catalog and whether all providers completed discovery.
|
||||
* Incomplete observations are never cached, allowing consumers to retain last-good state and
|
||||
* retry on their next request boundary.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries plus provider-completeness state.
|
||||
*/
|
||||
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
|
||||
const collected = await this.collect(options)
|
||||
return {
|
||||
skills: collected.entries
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary),
|
||||
complete: collected.cacheable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,7 +292,7 @@ export class SkillService extends Service {
|
||||
if (!isSkillName(name)) return undefined
|
||||
const collected = await this.collect(options)
|
||||
throwIfAborted(options.signal)
|
||||
const match = collected.find(entry => entry.candidate.name === name)
|
||||
const match = collected.entries.find(entry => entry.candidate.name === name)
|
||||
if (match === undefined) return undefined
|
||||
const definition = await waitWithAbort(
|
||||
match.provider.get(match.candidate, options),
|
||||
@@ -255,17 +300,21 @@ export class SkillService extends Service {
|
||||
)
|
||||
if (definition === undefined) return undefined
|
||||
validateDefinition(definition)
|
||||
if (definition.name !== match.candidate.name) {
|
||||
this.invalidateProvider(match.provider)
|
||||
return undefined
|
||||
}
|
||||
return definition
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
throwIfAborted(options.signal)
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
const key = collectCacheKey(options, providerRevision, runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
if (cached !== undefined) return { entries: cached, cacheable: true }
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
throwIfAborted(options.signal)
|
||||
@@ -277,7 +326,7 @@ export class SkillService extends Service {
|
||||
this.collectCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return result.entries
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +388,21 @@ export class SkillService extends Service {
|
||||
private invalidateCache(): void {
|
||||
this.providerRevision += 1
|
||||
this.collectCache.clear()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
/** Notify catalog observers without making their refresh work load-bearing. */
|
||||
private notifyChange(): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
|
||||
try {
|
||||
const returned: unknown = callback()
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -555,7 +555,9 @@ describe('SkillService registry', () => {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
const incomplete = await ctx.skills.snapshot()
|
||||
expect(incomplete.skills.map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(incomplete.complete).toBe(false)
|
||||
expect(flakyCalls).toBe(1)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(flakyCalls).toBe(2)
|
||||
@@ -566,6 +568,156 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
const dispose = ctx.skills.registerProvider(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)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
dispose()
|
||||
|
||||
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
|
||||
ctx.skills.registerProvider(replacement)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
|
||||
expect(replacement.listCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('emits catalog invalidations for live provider and runtime mutations', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([memorySkill('provider-skill', 'Provider', 10)])
|
||||
let changes = 0
|
||||
ctx.on('skills/change', () => { changes += 1 })
|
||||
|
||||
const disposeProvider = ctx.skills.registerProvider(provider)
|
||||
expect(changes).toBe(1)
|
||||
ctx.skills.invalidateProvider(new MemoryProvider([]))
|
||||
expect(changes).toBe(1)
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
expect(changes).toBe(2)
|
||||
|
||||
const disposeRuntime = ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
source: 'runtime',
|
||||
content: 'Runtime body.',
|
||||
})
|
||||
expect(changes).toBe(3)
|
||||
disposeRuntime()
|
||||
expect(changes).toBe(4)
|
||||
disposeProvider()
|
||||
expect(changes).toBe(5)
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
expect(changes).toBe(5)
|
||||
})
|
||||
|
||||
it('contains synchronous and asynchronous catalog observer failures', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') })
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment
|
||||
const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected')))
|
||||
let observed = 0
|
||||
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
|
||||
|
||||
const provider = new MemoryProvider([])
|
||||
expect(() => ctx.skills.registerProvider(provider)).not.toThrow()
|
||||
await Promise.resolve()
|
||||
expect(observed).toBe(1)
|
||||
expect(warnings).toEqual([
|
||||
'skills/change listener threw: Error: observer threw',
|
||||
'skills/change listener rejected: Error: observer rejected',
|
||||
])
|
||||
|
||||
disposeThrowing()
|
||||
disposeRejecting()
|
||||
disposeObserver()
|
||||
})
|
||||
|
||||
it('retries an in-flight catalog invalidated by its provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let release: (() => void) | undefined
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const provider = new MemoryProvider([memorySkill('stale-skill', 'Stale', 10)])
|
||||
const originalList = provider.list.bind(provider)
|
||||
provider.list = async (options) => {
|
||||
if (provider.listCalls === 0) {
|
||||
provider.listCalls += 1
|
||||
started.resolve(undefined)
|
||||
await gate
|
||||
return [memorySkill('stale-skill', 'Stale', 10)]
|
||||
}
|
||||
return await originalList(options)
|
||||
}
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started.promise
|
||||
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
release?.()
|
||||
|
||||
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
|
||||
expect(provider.listCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('invalidates a provider whose loaded definition changed identity', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
const provider: SkillProvider = {
|
||||
name: 'renamed',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return [{
|
||||
name: 'old-name',
|
||||
description: 'Old name',
|
||||
provider: 'renamed',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'old-name',
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
expect(await ctx.skills.get('old-name')).toBeUndefined()
|
||||
await ctx.skills.list()
|
||||
expect(listCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('returns undefined when a discovered candidate disappears before loading', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
name: 'vanished-body',
|
||||
async list() {
|
||||
return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains a provider rejection whose string coercion throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
|
||||
Reference in New Issue
Block a user