fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -9,9 +9,9 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a runtime embedded skill. 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.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Snapshots the lookup options, then returns detached model-invocable summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Uses one lookup-options snapshot to select and load the winner, rechecks cancellation after discovery or a cache hit, races provider loading against the same signal, then returns a detached full definition, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a detached runtime embedded skill. 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.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -21,13 +21,15 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token.
|
||||
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading.
|
||||
|
||||
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
|
||||
Each public lookup captures `cwd` and the abort-signal identity once before cache or provider work, and providers receive that frozen lookup record. The registry reads each returned candidate once, validates that snapshot, and detaches its resource metadata before caching it. The winning provider receives another detached candidate in `get(candidate, options)`, while `candidate.locator` preserves the exact provider-owned identity originally returned by `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. A loaded definition is detached again before it reaches the caller.
|
||||
|
||||
The registry validates fixed provider, candidate, runtime-registration, and loaded-definition fields before detachment: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Caller-owned objects masquerading as scalars are rejected without being frozen. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed, registry-owned catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
|
||||
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration detaches the accepted definition and nested resource metadata; later mutation of the registration object or a returned list/get value cannot rewrite the live skill. Registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
|
||||
@@ -81,9 +81,10 @@ export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?:
|
||||
|
||||
/** Caller context used for cwd-sensitive and abortable provider work. */
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
/** Workspace selector captured at lookup entry; providers receive a read-only snapshot. */
|
||||
readonly cwd?: string | undefined
|
||||
/** Abort discovery or loading work for the current caller. */
|
||||
signal?: AbortSignal | undefined
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
@@ -101,7 +102,8 @@ export interface SkillProvider {
|
||||
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
|
||||
/**
|
||||
* Load a complete skill body for a previously listed candidate.
|
||||
* @param candidate - the winning candidate originally returned by this provider.
|
||||
* @param candidate - a detached snapshot of the winning candidate; its opaque
|
||||
* `locator` retains the exact identity originally returned by this provider.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill body, or `undefined` if it is no longer loadable.
|
||||
*/
|
||||
@@ -194,10 +196,18 @@ export class SkillService extends Service {
|
||||
// replacement of `provider.list`/`provider.get` after registration inert.
|
||||
// In particular, cleanup must never re-read caller-owned `provider.name`:
|
||||
// an HMR host may mutate or reuse that object before its old fiber unloads.
|
||||
const name = provider.name
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputList = provider.list
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const inputGet = provider.get
|
||||
if (typeof name !== 'string') throw new TypeError('skill provider name must be a string')
|
||||
if (typeof inputList !== 'function') throw new TypeError(`skill provider "${name}" list must be a function`)
|
||||
if (typeof inputGet !== 'function') throw new TypeError(`skill provider "${name}" get must be a function`)
|
||||
const snapshot: SkillProvider = Object.freeze({
|
||||
name: provider.name,
|
||||
list: provider.list.bind(provider),
|
||||
get: provider.get.bind(provider),
|
||||
name,
|
||||
list: inputList.bind(provider),
|
||||
get: inputGet.bind(provider),
|
||||
})
|
||||
const dispose = this.ctx.effect(function* (this: SkillService) {
|
||||
if (snapshot.name === RUNTIME_PROVIDER) {
|
||||
@@ -223,7 +233,9 @@ export class SkillService extends Service {
|
||||
* Register a runtime skill contribution. Runtime registrations are treated as
|
||||
* embedded provider entries with project-over-user priority. Same-name runtime
|
||||
* registrations are first-wins: a duplicate logs a warning and gets a no-op
|
||||
* disposer so it cannot remove the active contribution.
|
||||
* disposer so it cannot remove the active contribution. The registry detaches
|
||||
* the accepted definition, including nested resource metadata, so later caller
|
||||
* mutation cannot rewrite the live contribution.
|
||||
* @param skill - the complete skill definition to expose for discovery.
|
||||
* @returns the exact Cordis effect disposer that removes this runtime
|
||||
* contribution and invalidates caches; composite effects may yield it
|
||||
@@ -250,12 +262,15 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace.
|
||||
* List model-invocable skill summaries for a workspace. The lookup options are
|
||||
* snapshotted before discovery, and every returned summary is detached from the
|
||||
* cached provider catalog.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
const accepted = snapshotLookupOptions(options)
|
||||
return (await this.collect(accepted))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
@@ -263,20 +278,32 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* Load one full skill definition by name. One lookup-options snapshot selects
|
||||
* and loads the winner; the provider receives detached candidate metadata with
|
||||
* its opaque locator identity preserved, and the returned definition is also
|
||||
* detached from provider-owned data. Cancellation is rechecked after catalog
|
||||
* selection (including a cache hit), and provider loading is raced against the
|
||||
* same signal so an uncooperative provider cannot hang the caller.
|
||||
* @param name - kebab-case skill name.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill, including body content, or `undefined`.
|
||||
*/
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
if (!isSkillName(name)) return undefined
|
||||
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
|
||||
const accepted = snapshotLookupOptions(options)
|
||||
const collected = await this.collect(accepted)
|
||||
throwIfAborted(accepted.signal)
|
||||
const match = collected.find(entry => entry.candidate.name === name)
|
||||
if (match === undefined) return undefined
|
||||
return await match.provider.get(match.candidate, options)
|
||||
const definition = await waitWithAbort(
|
||||
match.provider.get(copyCandidate(match.candidate), accepted),
|
||||
accepted.signal,
|
||||
)
|
||||
return definition === undefined ? undefined : snapshotDefinition(definition)
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
@@ -285,7 +312,7 @@ export class SkillService extends Service {
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
@@ -316,7 +343,7 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
@@ -340,9 +367,12 @@ export class SkillService extends Service {
|
||||
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
|
||||
}
|
||||
if (listed === undefined) continue
|
||||
if (!Array.isArray(listed)) {
|
||||
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
|
||||
}
|
||||
for (const candidate of listed) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
const snapshot = snapshotCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate: snapshot, provider, providerOrder: order, localOrder })
|
||||
localOrder += 1
|
||||
}
|
||||
}
|
||||
@@ -377,28 +407,161 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read provider candidate data once and detach it while preserving its opaque locator identity. */
|
||||
function copyCandidate(candidate: SkillCandidate, providerName?: string): SkillCandidate {
|
||||
const name = candidate.name
|
||||
const description = candidate.description
|
||||
const whenToUse = candidate.whenToUse
|
||||
const disableModelInvocation = candidate.disableModelInvocation
|
||||
const source = candidate.source
|
||||
const provider = candidate.provider
|
||||
const resourceBase = candidate.resourceBase
|
||||
const rank = candidate.rank
|
||||
const locator = candidate.locator
|
||||
const path = candidate.path
|
||||
const metadata = candidate.metadata
|
||||
const accepted: SkillCandidate = {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
rank,
|
||||
// `locator` is the one deliberately provider-owned capability in a
|
||||
// candidate. Its exact identity must round-trip back to provider.get().
|
||||
locator,
|
||||
...path !== undefined ? { path } : {},
|
||||
...metadata !== undefined ? { metadata } : {},
|
||||
}
|
||||
// Validate the exact scalar snapshot before cloning nested data. This keeps a
|
||||
// malformed candidate's provider-contract error from being masked by an
|
||||
// unrelated DataCloneError in its metadata.
|
||||
if (providerName !== undefined) validateCandidate(accepted, providerName)
|
||||
return {
|
||||
...accepted,
|
||||
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
|
||||
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize one provider result into the registry-owned catalog snapshot. */
|
||||
function snapshotCandidate(candidate: SkillCandidate, providerName: string): SkillCandidate {
|
||||
return copyCandidate(candidate, providerName)
|
||||
}
|
||||
|
||||
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
|
||||
if (typeof candidate.name !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
|
||||
}
|
||||
if (!SKILL_NAME.test(candidate.name)) {
|
||||
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
|
||||
}
|
||||
if (typeof candidate.description !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
|
||||
}
|
||||
if (candidate.description.length === 0) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
|
||||
}
|
||||
if (!Number.isFinite(candidate.rank)) {
|
||||
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
|
||||
}
|
||||
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
|
||||
}
|
||||
if (typeof candidate.source !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
|
||||
}
|
||||
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
|
||||
}
|
||||
if (typeof candidate.provider !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
|
||||
}
|
||||
if (candidate.provider !== providerName) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
|
||||
}
|
||||
if (candidate.path !== undefined && typeof candidate.path !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
|
||||
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
|
||||
// Read every caller-owned top-level field once so validation and storage use
|
||||
// one coherent definition even when JavaScript accessors are involved.
|
||||
const name = skill.name
|
||||
const description = skill.description
|
||||
const whenToUse = skill.whenToUse
|
||||
const disableModelInvocation = skill.disableModelInvocation
|
||||
const source = skill.source
|
||||
const inputProvider = skill.provider
|
||||
const provider = inputProvider === undefined ? RUNTIME_PROVIDER : inputProvider
|
||||
const resourceBase = skill.resourceBase
|
||||
const content = skill.content
|
||||
const path = skill.path
|
||||
const metadata = skill.metadata
|
||||
if (typeof name !== 'string') throw new TypeError('runtime skill name must be a string')
|
||||
if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`)
|
||||
if (typeof description !== 'string') throw new TypeError(`skill "${name}" description must be a string`)
|
||||
if (description.length === 0) throw new Error(`skill "${name}" requires a description`)
|
||||
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`skill "${name}" disableModelInvocation must be a boolean`)
|
||||
}
|
||||
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`skill "${name}" whenToUse must be a string`)
|
||||
if (typeof source !== 'string') throw new TypeError(`skill "${name}" source must be a string`)
|
||||
if (typeof provider !== 'string') throw new TypeError(`skill "${name}" provider must be a string`)
|
||||
if (typeof content !== 'string') throw new TypeError(`skill "${name}" content must be a string`)
|
||||
if (path !== undefined && typeof path !== 'string') throw new TypeError(`skill "${name}" path must be a string`)
|
||||
return {
|
||||
...skill,
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
source: skill.source,
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
|
||||
content,
|
||||
...path !== undefined ? { path } : {},
|
||||
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Detach a provider-loaded definition before it crosses back to the caller. */
|
||||
function snapshotDefinition(skill: SkillDefinition): SkillDefinition {
|
||||
const name = skill.name
|
||||
const description = skill.description
|
||||
const whenToUse = skill.whenToUse
|
||||
const disableModelInvocation = skill.disableModelInvocation
|
||||
const source = skill.source
|
||||
const provider = skill.provider
|
||||
const resourceBase = skill.resourceBase
|
||||
const content = skill.content
|
||||
const path = skill.path
|
||||
const metadata = skill.metadata
|
||||
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
|
||||
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
|
||||
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
|
||||
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
|
||||
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
|
||||
}
|
||||
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
|
||||
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
|
||||
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
|
||||
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
|
||||
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
|
||||
content,
|
||||
...path !== undefined ? { path } : {},
|
||||
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +574,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,9 +604,19 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number,
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
/** Capture one lookup identity before any provider or cache async boundary. */
|
||||
function snapshotLookupOptions(options: SkillLookupOptions): Readonly<SkillLookupOptions> {
|
||||
const cwd = options.cwd
|
||||
const signal = options.signal
|
||||
return Object.freeze({
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
signal.throwIfAborted()
|
||||
throwIfAborted(signal)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
@@ -467,12 +640,28 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined):
|
||||
})
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
/** Throw a total Error for an already-aborted lookup. */
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw toError(signal.reason)
|
||||
}
|
||||
|
||||
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
|
||||
function toError(error: unknown): Error {
|
||||
try {
|
||||
if (error instanceof Error) return error
|
||||
} catch {
|
||||
// A hostile proxy may throw during instanceof; fall through to the total renderer.
|
||||
}
|
||||
return new Error(errorMessage(error))
|
||||
}
|
||||
|
||||
/** Render an arbitrary provider failure without letting coercion escape containment. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
return '[unrenderable thrown value]'
|
||||
}
|
||||
}
|
||||
|
||||
export default SkillService
|
||||
|
||||
@@ -165,6 +165,480 @@ describe('SkillService registry', () => {
|
||||
expect(() => ctx.skills.registerProvider(replacement)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects malformed provider and candidate scalar fields without freezing caller objects', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const badProviderName = { value: 'object-provider' }
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: badProviderName as unknown as string,
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})).toThrow('skill provider name must be a string')
|
||||
expect(Object.isFrozen(badProviderName)).toBe(false)
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'bad-list',
|
||||
list: { bind() {} } as unknown as SkillProvider['list'],
|
||||
get: () => Promise.resolve(undefined),
|
||||
})).toThrow('list must be a function')
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'bad-get',
|
||||
list: () => Promise.resolve([]),
|
||||
get: { bind() {} } as unknown as SkillProvider['get'],
|
||||
})).toThrow('get must be a function')
|
||||
|
||||
const badDescription = { value: 'object-description' }
|
||||
ctx.skills.registerProvider({
|
||||
name: 'bad-candidate',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-candidate', 'placeholder', 1),
|
||||
provider: 'bad-candidate',
|
||||
description: badDescription as unknown as string,
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(ctx.skills.list()).rejects.toThrow('non-string description')
|
||||
expect(Object.isFrozen(badDescription)).toBe(false)
|
||||
|
||||
const badBoolean = new Context()
|
||||
await badBoolean.plugin(SkillService)
|
||||
badBoolean.skills.registerProvider({
|
||||
name: 'bad-boolean',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-boolean', 'Bad boolean', 1),
|
||||
provider: 'bad-boolean',
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
|
||||
})
|
||||
|
||||
it('rejects non-array provider results and every malformed candidate scalar', async () => {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
badList.skills.registerProvider({
|
||||
name: 'non-array-list',
|
||||
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
|
||||
|
||||
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
|
||||
{ patch: { whenToUse: 1 as unknown as string }, expected: 'non-string whenToUse' },
|
||||
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'non-string source' },
|
||||
{ patch: { rank: '1' as unknown as number }, expected: 'invalid rank' },
|
||||
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'non-string provider' },
|
||||
{ patch: { path: 1 as unknown as string }, expected: 'non-string path' },
|
||||
]
|
||||
for (const [index, { patch, expected }] of cases.entries()) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `candidate-provider-${index}`
|
||||
const candidate = {
|
||||
name: `candidate-${index}`,
|
||||
description: 'Candidate',
|
||||
whenToUse: 'Use this candidate.',
|
||||
disableModelInvocation: false,
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'candidate',
|
||||
path: '/skills/candidate/SKILL.md',
|
||||
...patch,
|
||||
} as SkillCandidate
|
||||
ctx.skills.registerProvider({
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([candidate]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list()).rejects.toThrow(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshots lookup options before asynchronous discovery and loading', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const listCwds: (string | undefined)[] = []
|
||||
const getCwds: (string | undefined)[] = []
|
||||
ctx.skills.registerProvider({
|
||||
name: 'contextual',
|
||||
async list(options) {
|
||||
listCwds.push(options.cwd)
|
||||
await gate
|
||||
const name = options.cwd === '/workspace/a' ? 'skill-a' : 'skill-b'
|
||||
return [
|
||||
{ name, description: name, provider: 'contextual', source: 'test', rank: 1, locator: name },
|
||||
{ name: 'vanished', description: 'Vanished', provider: 'contextual', source: 'test', rank: 2, locator: 'vanished' },
|
||||
]
|
||||
},
|
||||
async get(candidate, options) {
|
||||
getCwds.push(options.cwd)
|
||||
if (candidate.name === 'vanished') return undefined
|
||||
return { ...candidate, content: `${options.cwd}:${candidate.name}` }
|
||||
},
|
||||
})
|
||||
|
||||
const listOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
|
||||
const pending = ctx.skills.list(listOptions)
|
||||
listOptions.cwd = '/workspace/b'
|
||||
release?.()
|
||||
|
||||
expect((await pending).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
|
||||
expect((await ctx.skills.list({ cwd: '/workspace/a' })).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
|
||||
expect(listCwds).toEqual(['/workspace/a'])
|
||||
|
||||
const getOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
|
||||
const loading = ctx.skills.get('skill-a', getOptions)
|
||||
getOptions.cwd = '/workspace/b'
|
||||
expect((await loading)?.content).toBe('/workspace/a:skill-a')
|
||||
expect(await ctx.skills.get('vanished', { cwd: '/workspace/a' })).toBeUndefined()
|
||||
expect(getCwds).toEqual(['/workspace/a', '/workspace/a'])
|
||||
})
|
||||
|
||||
it('rechecks cancellation after cached discovery before provider loading', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let getCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
name: 'cached',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'cached-skill',
|
||||
description: 'Cached skill',
|
||||
provider: 'cached',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'cached',
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
getCalls += 1
|
||||
return { ...candidate, content: 'Cached body.' }
|
||||
},
|
||||
})
|
||||
await ctx.skills.list({ cwd: '/workspace/cache' })
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled after cached discovery')
|
||||
|
||||
const pending = ctx.skills.get('cached-skill', {
|
||||
cwd: '/workspace/cache',
|
||||
signal: controller.signal,
|
||||
})
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(getCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('stops waiting for cached provider loading when a hostile abort reason fires', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
let seenSignal: AbortSignal | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const held = new Promise<SkillDefinition>((resolve) => {
|
||||
release = () => {
|
||||
resolve({
|
||||
name: 'held-skill',
|
||||
description: 'Held skill',
|
||||
provider: 'held',
|
||||
source: 'test',
|
||||
content: 'Held body.',
|
||||
})
|
||||
}
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
name: 'held',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'held-skill',
|
||||
description: 'Held skill',
|
||||
provider: 'held',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'held',
|
||||
}]
|
||||
},
|
||||
get(_candidate, options) {
|
||||
seenSignal = options.signal
|
||||
markStarted?.()
|
||||
return held
|
||||
},
|
||||
})
|
||||
await ctx.skills.list({ cwd: '/workspace/cache' })
|
||||
const controller = new AbortController()
|
||||
const hostileReason = {
|
||||
[Symbol.toPrimitive]() {
|
||||
throw new Error('abort reason coercion failed')
|
||||
},
|
||||
}
|
||||
const pending = ctx.skills.get('held-skill', {
|
||||
cwd: '/workspace/cache',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const outcome = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error instanceof Error && error.message === '[unrenderable thrown value]'
|
||||
? 'aborted'
|
||||
: 'other-error',
|
||||
)
|
||||
await started
|
||||
controller.abort(hostileReason)
|
||||
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
|
||||
])
|
||||
release?.()
|
||||
await pending.catch(() => undefined)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
expect(settled).toBe('aborted')
|
||||
})
|
||||
|
||||
it('detaches cached candidates and loaded definitions while preserving locator identity', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const locator = { id: 'provider-owned' }
|
||||
const candidate: SkillCandidate = {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
rank: 1,
|
||||
locator,
|
||||
path: '/skills/stable/SKILL.md',
|
||||
metadata: { owner: 'candidate' },
|
||||
}
|
||||
const definition: SkillDefinition = {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'definition resources' },
|
||||
path: '/skills/stable/SKILL.md',
|
||||
metadata: { owner: 'definition' },
|
||||
content: 'Stable body.',
|
||||
}
|
||||
let listCalls = 0
|
||||
let received: SkillCandidate | undefined
|
||||
ctx.skills.registerProvider({
|
||||
name: 'detached',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return [candidate]
|
||||
},
|
||||
async get(loaded) {
|
||||
received = loaded
|
||||
return definition
|
||||
},
|
||||
})
|
||||
|
||||
const first = await ctx.skills.list()
|
||||
candidate.name = 'Bad_Name'
|
||||
candidate.description = ''
|
||||
if (candidate.resourceBase?.kind === 'opaque') candidate.resourceBase.description = 'mutated candidate'
|
||||
if (candidate.metadata) candidate.metadata.owner = 'mutated candidate'
|
||||
if (first[0]?.resourceBase?.kind === 'opaque') first[0].resourceBase.description = 'mutated summary'
|
||||
|
||||
const second = await ctx.skills.list()
|
||||
expect(second).toEqual([expect.objectContaining({
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
})])
|
||||
expect(listCalls).toBe(1)
|
||||
|
||||
const loaded = await ctx.skills.get('stable-skill')
|
||||
expect(received).not.toBe(candidate)
|
||||
expect(received?.locator).toBe(locator)
|
||||
expect(received).toMatchObject({
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
metadata: { owner: 'candidate' },
|
||||
})
|
||||
expect(loaded).not.toBe(definition)
|
||||
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated definition output'
|
||||
if (loaded?.metadata) loaded.metadata.owner = 'mutated definition output'
|
||||
|
||||
expect(await ctx.skills.get('stable-skill')).toMatchObject({
|
||||
resourceBase: { kind: 'opaque', description: 'definition resources' },
|
||||
metadata: { owner: 'definition' },
|
||||
})
|
||||
expect(definition).toMatchObject({
|
||||
resourceBase: { kind: 'opaque', description: 'definition resources' },
|
||||
metadata: { owner: 'definition' },
|
||||
})
|
||||
})
|
||||
|
||||
it('detaches runtime registrations and every public resource view', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
|
||||
const metadata = { owner: 'runtime' }
|
||||
ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
whenToUse: 'When runtime data is needed.',
|
||||
disableModelInvocation: false,
|
||||
source: 'runtime',
|
||||
resourceBase,
|
||||
metadata,
|
||||
content: 'Runtime body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'z-runtime',
|
||||
description: 'Second runtime skill',
|
||||
source: 'runtime',
|
||||
content: 'Second runtime body.',
|
||||
})
|
||||
resourceBase.description = 'mutated registration'
|
||||
metadata.owner = 'mutated registration'
|
||||
|
||||
const listed = await ctx.skills.list()
|
||||
const loaded = await ctx.skills.get('runtime-skill')
|
||||
expect(listed[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
|
||||
expect(loaded?.metadata).toEqual({ owner: 'runtime' })
|
||||
if (listed[0]?.resourceBase?.kind === 'opaque') listed[0].resourceBase.description = 'mutated list output'
|
||||
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated get output'
|
||||
if (loaded?.metadata) loaded.metadata.owner = 'mutated get output'
|
||||
|
||||
expect((await ctx.skills.list())[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
|
||||
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
|
||||
resourceBase: { kind: 'opaque', description: 'runtime resources' },
|
||||
metadata: { owner: 'runtime' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed runtime and loaded-definition scalar fields without freezing them', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const runtimeDescription = { value: 'runtime-description' }
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'bad-runtime',
|
||||
description: runtimeDescription as unknown as string,
|
||||
source: 'runtime',
|
||||
content: 'body',
|
||||
})).toThrow('description must be a string')
|
||||
expect(Object.isFrozen(runtimeDescription)).toBe(false)
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'bad-runtime-boolean',
|
||||
description: 'Runtime',
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
source: 'runtime',
|
||||
content: 'body',
|
||||
})).toThrow('disableModelInvocation must be a boolean')
|
||||
expect(() => ctx.skills.register({
|
||||
name: 'bad-runtime-provider',
|
||||
description: 'Runtime',
|
||||
source: 'runtime',
|
||||
provider: null as unknown as string,
|
||||
content: 'body',
|
||||
})).toThrow('provider must be a string')
|
||||
|
||||
const loadedContent = { value: 'loaded-content' }
|
||||
ctx.skills.registerProvider({
|
||||
name: 'bad-definition',
|
||||
list: () => Promise.resolve([{
|
||||
name: 'bad-definition',
|
||||
description: 'Candidate',
|
||||
provider: 'bad-definition',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'bad-definition',
|
||||
}]),
|
||||
get: candidate => Promise.resolve({
|
||||
...candidate,
|
||||
content: loadedContent as unknown as string,
|
||||
}),
|
||||
})
|
||||
await expect(ctx.skills.get('bad-definition')).rejects.toThrow('content must be a string')
|
||||
expect(Object.isFrozen(loadedContent)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects every other malformed runtime scalar', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
type Registration = Parameters<typeof ctx.skills.register>[0]
|
||||
const valid: Registration = {
|
||||
name: 'runtime-validation',
|
||||
description: 'Runtime validation',
|
||||
whenToUse: 'Use this runtime skill.',
|
||||
disableModelInvocation: false,
|
||||
source: 'runtime',
|
||||
provider: 'runtime-validation',
|
||||
content: 'Runtime body.',
|
||||
path: '/skills/runtime-validation/SKILL.md',
|
||||
}
|
||||
const cases: { patch: Partial<Registration>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'runtime' } as unknown as string }, expected: 'runtime skill name must be a string' },
|
||||
{ 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: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
|
||||
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
|
||||
]
|
||||
for (const { patch, expected } of cases) {
|
||||
expect(() => ctx.skills.register({ ...valid, ...patch })).toThrow(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects every malformed scalar in provider-loaded definitions', async () => {
|
||||
const cases: { patch: Partial<SkillDefinition>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'loaded' } as unknown as string }, expected: 'loaded skill name must be a string' },
|
||||
{ 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: { 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' },
|
||||
{ patch: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
|
||||
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
|
||||
]
|
||||
for (const [index, { patch, expected }] of cases.entries()) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `definition-provider-${index}`
|
||||
const skillName = `definition-${index}`
|
||||
ctx.skills.registerProvider({
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([{
|
||||
name: skillName,
|
||||
description: 'Candidate',
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'definition',
|
||||
}]),
|
||||
get: () => Promise.resolve({
|
||||
name: skillName,
|
||||
description: 'Definition',
|
||||
whenToUse: 'Use this definition.',
|
||||
disableModelInvocation: false,
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
content: 'Definition body.',
|
||||
path: '/skills/definition/SKILL.md',
|
||||
...patch,
|
||||
} as SkillDefinition),
|
||||
})
|
||||
|
||||
await expect(ctx.skills.get(skillName)).rejects.toThrow(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const defaultedService = new SkillService(new Context())
|
||||
expect(await defaultedService.list()).toEqual([])
|
||||
@@ -282,6 +756,34 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('contains a provider rejection whose string coercion throws', 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 hostileFailure = {
|
||||
toString() {
|
||||
throw new Error('provider failure coercion failed')
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
name: 'hostile-failure',
|
||||
list() {
|
||||
// Deliberately violate the provider contract to prove containment is total.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(hostileFailure)
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list()).resolves.toEqual([])
|
||||
expect(warnings).toEqual([
|
||||
'skill provider "hostile-failure" skipped: [unrenderable thrown value]',
|
||||
])
|
||||
})
|
||||
|
||||
it('abandons an in-flight catalog when provider registrations change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
|
||||
Reference in New Issue
Block a user