refactor(skill): canonicalize invocation policy

This commit is contained in:
Tianyi Cui
2026-07-29 22:51:47 +08:00
parent 2b1d180a63
commit 12832886c5
23 changed files with 134 additions and 85 deletions

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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md
2026-07-28-skill-invocation-policy.md: dd97f6f9d26de7588cac61e480c3a16fa6e6173a
2026-07-28-skill-invocation-policy.zh.md: c4c89bbe30a0bc53f36250bde74f2c66903f29d2
2026-07-28-skill-invocation-policy.md: 2010196c8373c89a49056a5d8acd41e2e4c307ce
2026-07-28-skill-invocation-policy.zh.md: 129c5b7f62b14a0e9b9bc5ae6107ba946a5a54d7

View File

@@ -12,11 +12,11 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su
## Decision
`SkillSummary` carries an optional typed `invocation: SkillInvocationPolicy` object. When present, its `modelInvocable: boolean` and `userInvocable: boolean` fields are both required, positive, and symmetric; future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist. The local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields and their defaults into the normalized typed policy.
`SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields and their defaults into the normalized typed policy.
`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field; an absent policy permits both surfaces. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill.
`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill.
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills the other field's default whenever either key is present. Camel-case external spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias.
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. Camel-case external spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
@@ -24,7 +24,7 @@ These rules permit all four combinations:
| Policy | Model surface | User surface |
|---|---|---|
| no policy, or `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: false }` | included | excluded |
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |

View File

@@ -12,11 +12,11 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
## 决策
`SkillSummary` 包含一个可选且类型明确的 `invocation: SkillInvocationPolicy` 对象。该对象存在时`modelInvocable: boolean``userInvocable: boolean` 都是必填、正向且对称的字段;未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,`modelInvocable: boolean``userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)``isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)``isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,并在任一键存在时填充另一个字段的默认值。外部使用的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。外部使用的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill本次改动不新增让浏览器直接加载 skill 的 RPC。
@@ -24,7 +24,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
| 策略 | 模型侧接口 | 用户侧接口 |
|---|---|---|
| 无策略,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |

View File

@@ -1241,7 +1241,7 @@ export interface Config {
}
```
Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts)
Source: [`packages/skill/skill/src/index.ts:144`](../packages/skill/skill/src/index.ts)
## `@deepseek-ai/dsh-skill-local`

View File

@@ -1645,7 +1645,7 @@ registerProvider(provider: SkillProvider): () => void
* 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
* receives a no-op disposer so it cannot remove the winner.
* @param skill - the complete skill definition to expose for discovery.
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
*/
register(skill: SkillRegistration): () => void
@@ -1673,7 +1673,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md)
Source: [`packages/skill/skill/src/index.ts:167`](../../packages/skill/skill/src/index.ts)
Source: [`packages/skill/skill/src/index.ts:172`](../../packages/skill/skill/src/index.ts)
## `ctx.spillStore` — `SpillStore` (abstract seam)

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 docs/core-data-structures/skills.md
skills.md: 850aa589055f5e611619868ef6d05b3165f917a4
skills.zh.md: da36f301b6a8b38d83a190f759f9f2ed82b8ce4b
skills.md: 2524606ad058d5cbf81e287a7001777b4a125aaf
skills.zh.md: 7bc2d2165172da46bde0cfe0b9a1be279ea8af32

View File

@@ -62,7 +62,7 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
## Summaries, candidates, and complete definitions
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans without turning arbitrary frontmatter into the domain model.
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model.
```ts type-equiv
/** Invocation controls shared by skill discovery consumers. */
@@ -83,8 +83,8 @@ interface SkillSummary {
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Optional model and user invocation controls. */
readonly invocation?: SkillInvocationPolicy
/** Resolved model and user invocation controls. */
readonly invocation: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
@@ -94,7 +94,7 @@ interface SkillSummary {
}
```
`ctx.skills.list()` preserves all four policy combinations. An absent `invocation` object permits both surfaces; when present, both booleans are required. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding positive field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, applies their defaults, and projects them into this normalized policy.
`ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding required field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, defaults omitted fields to `true`, and projects every parsed skill into this normalized policy.
`SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`.
@@ -134,11 +134,16 @@ interface SkillDefinition extends SkillSummary {
}
```
Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches.
Runtime skill inputs may omit invocation controls and the provider label. The registry resolves both defaults once, then uses the same complete definition shape and first-wins collection order as providers. The returned disposer removes the contribution and invalidates discovery caches.
```ts type-equiv
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
/** Invocation controls; omission permits both model and user surfaces. */
readonly invocation?: SkillInvocationPolicy
/** Provider label; omission uses the registry-owned runtime provider. */
readonly provider?: string
}
```
## Lookup and configuration

View File

@@ -62,7 +62,7 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
## 摘要、候选项与完整定义
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,而不会把任意 frontmatter 纳入领域模型。
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。
```ts type-equiv
/** Invocation controls shared by skill discovery consumers. */
@@ -83,8 +83,8 @@ interface SkillSummary {
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Optional model and user invocation controls. */
readonly invocation?: SkillInvocationPolicy
/** Resolved model and user invocation controls. */
readonly invocation: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
@@ -94,7 +94,7 @@ interface SkillSummary {
}
```
`ctx.skills.list()` 保留全部四种策略组合。缺少 `invocation` 对象时两个接口均允许调用;该对象存在时,两个布尔字段都为必填。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`应用其默认值,再将其投影到这个规范化策略
`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。
`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。
@@ -134,11 +134,16 @@ interface SkillDefinition extends SkillSummary {
}
```
运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。
```ts type-equiv
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
/** Invocation controls; omission permits both model and user surfaces. */
readonly invocation?: SkillInvocationPolicy
/** Provider label; omission uses the registry-owned runtime provider. */
readonly provider?: string
}
```
## 查找与配置

View File

@@ -754,7 +754,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'register(skill: SkillRegistration): () => void',
jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the skill definition input; omitted invocation and provider fields receive defaults.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */',
},
{
signature: 'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
@@ -2353,7 +2353,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillRegistration',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n readonly provider?: string;\n};',
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'invocation\' | \'provider\'> & {\n readonly invocation?: SkillInvocationPolicy;\n readonly provider?: string;\n};',
},
{
name: 'SkillResourceBase',
@@ -2365,7 +2365,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation?: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
declaration: 'export interface SkillSummary {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly invocation: SkillInvocationPolicy;\n readonly source: SkillSource;\n readonly provider: string;\n readonly resourceBase?: SkillResourceBase;\n}',
},
{
name: 'SpillLocator',

View File

@@ -16,7 +16,6 @@ import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -36,8 +35,9 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
import { GoalError } from '@deepseek-ai/dsh-goal'
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
// Type-only edge: resolves `ctx.get('commands')` and the `commands/change` event.
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
@@ -1426,7 +1426,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
try {
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => isModelInvocable(skill) && isUserInvocable(skill))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,

View File

@@ -187,6 +187,7 @@ describe('skill.list', () => {
return Promise.resolve([
{
name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
invocation: { modelInvocable: true, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{

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-local/README.md
README.md: cbef5e489e4340e10f3337ffd1bdb64490af4bc1
README.zh.md: ae1e5658c5820ecf5a9a89f76f7aba4d6ad3e742
README.md: addabda98490b49736c1aa5053f5734978079a20
README.zh.md: 1ab1a43d70d8393ba8c837f384e3a422cfdc1d3c

View File

@@ -38,7 +38,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. If either field is present, the provider fills both positive internal policy values from these external defaults. The camel-case spellings `disableModelInvocation`, `modelInvocable`, and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. The camel-case spellings `disableModelInvocation`, `modelInvocable`, and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
## Model Experience

View File

@@ -38,7 +38,7 @@
Skill 可以是单层目录 bundle`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`。v1 刻意不支持发现嵌套的 `**/SKILL.md`。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name``description`,以及可选的 `whenToUse``metadata``disable-model-invocation``user-invocable`。名称必须使用 kebab-case。
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0``disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill`user-invocable: false` 会从面向用户的命令中排除该 skill。任一字段存在时,提供方都会按照这些外部默认值填充两个正向内部策略值。系统会拒绝驼峰形式的 `disableModelInvocation``modelInvocable``userInvocable` 并记录警告,而不会将其作为兼容别名。
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0``disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。系统会拒绝驼峰形式的 `disableModelInvocation``modelInvocable``userInvocable` 并记录警告,而不会将其作为兼容别名。
## 模型体验

View File

@@ -75,7 +75,7 @@ interface ParsedSkill {
name: string
description: string
whenToUse?: string
invocation?: SkillInvocationPolicy
invocation: SkillInvocationPolicy
metadata?: Record<string, unknown>
content: string
}
@@ -137,7 +137,7 @@ export class LocalSkillProvider implements SkillProvider {
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
invocation: parsed.invocation,
source: candidate.source,
provider: this.name,
resourceBase: { kind: 'directory', path: locator.directory },
@@ -185,7 +185,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
invocation: parsed.invocation,
provider: 'local',
source: root.source,
rank: root.rank,
@@ -275,7 +275,7 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
name,
description,
...optionalString(parsed.data, 'whenToUse'),
...invocation === undefined ? {} : { invocation },
invocation,
...optionalMetadata(parsed.data),
content: parsed.body.trim(),
}
@@ -428,13 +428,12 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
}
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy | undefined {
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy {
rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')
const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')
const userInvocable = frontmatterBoolean(data, 'user-invocable')
if (disableModelInvocation === undefined && userInvocable === undefined) return undefined
return {
modelInvocable: disableModelInvocation !== true,
userInvocable: userInvocable !== false,

View File

@@ -244,7 +244,11 @@ describe('LocalSkillProvider', () => {
'rich-skill',
'user-only-skill',
])
expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true })
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect(await ctx.skills.get('no-trailing-body')).toMatchObject({
invocation: { modelInvocable: true, userInvocable: true },
})
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
invocation: { modelInvocable: false, userInvocable: true },
content: 'User-only.',

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: fd994fb2d20d0d8027b33d1092b9a94de0375e15
README.zh.md: bb4e3ea8e764cf96c44235029342e2d5f9b5f578
README.md: d2b75ff73e97089ed3d8da4192e71898e6b7eac6
README.zh.md: 1b559e7584fc1646e868d933d13469dfb5d3cef4

View File

@@ -13,7 +13,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
- `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.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. The result is invocation-neutral; consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `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 regardless of invocation policy.
- `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.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `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.
### Config
@@ -23,16 +23,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Invocation policy
`SkillSummary.invocation` is an optional typed policy object. When present, its required positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently; omitting the object preserves the default model-and-user behavior. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs.
`SkillSummary.invocation` is a required typed policy object whose positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently. Providers return this resolved shape on every candidate and definition; only the `SkillRegistration` input may omit it, in which case `register()` supplies `{ modelInvocable: true, userInvocable: true }`. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs.
| Policy | Model | User |
|---|---|---|
| no `invocation`, or `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: false }` | included | excluded |
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field, with an absent policy permitting both surfaces. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
## Provider Contract
@@ -44,7 +44,7 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou
## 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 definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is 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 definitions and nested resource metadata are borrowed readonly; the service materializes one top-level definition to supply omitted invocation and provider defaults. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Consumer boundary

View File

@@ -13,7 +13,7 @@
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect可安全用于 HMR热模块替换Cordis 返回的原始 disposer 支持有序组合拆卸。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)``isUserInvocable(skill)`
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
### 配置
@@ -23,16 +23,16 @@
### 调用策略
`SkillSummary.invocation` 是一个可选的类型化策略对象。该对象存在时,其必填的正向布尔字段 `modelInvocable``userInvocable` 分别描述两个接口;省略该对象时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
`SkillSummary.invocation` 是一个必填的类型化策略对象,其正向布尔字段 `modelInvocable``userInvocable` 分别描述两个接口。提供方会在每个候选项和定义中返回这一已解析形状;只有 `SkillRegistration` 输入可以省略它,此时 `register()` 会补入 `{ modelInvocable: true, userInvocable: true }`。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
| 策略 | 模型 | 用户 |
|---|---|---|
|`invocation`,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
`isModelInvocable(skill)``isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
`isModelInvocable(skill)``isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约
@@ -44,7 +44,7 @@
## 运行时 skill
`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化提供默认 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。
`ctx.skills.register(...)` 是嵌入式运行时 skill 的便利接口。运行时 skill 使用 rank `250`:项目提供方可覆盖它们,它们则覆盖已发布本地提供方的自定义根目录和用户根目录。运行时定义和嵌套资源元数据均以只读方式借用;服务只物化补入默认调用策略和 `provider` 所需的顶层定义。运行时贡献内的注册使用先到先得,因此重复贡献无法通过其 disposer 移除当前生效的贡献。
## 消费方边界

View File

@@ -52,8 +52,8 @@ export interface SkillSummary {
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Optional model and user invocation controls. */
readonly invocation?: SkillInvocationPolicy
/** Resolved model and user invocation controls. */
readonly invocation: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
@@ -85,7 +85,12 @@ export interface SkillDefinition extends SkillSummary {
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
export type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
/** Invocation controls; omission permits both model and user surfaces. */
readonly invocation?: SkillInvocationPolicy
/** Provider label; omission uses the registry-owned runtime provider. */
readonly provider?: string
}
/** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions {
@@ -97,20 +102,20 @@ export interface SkillLookupOptions {
/**
* Return whether a skill may be advertised to and loaded by a model.
* @param skill - skill metadata carrying optional invocation controls.
* @returns whether the normalized policy permits model invocation.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits model invocation.
*/
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.modelInvocable !== false
return skill.invocation.modelInvocable
}
/**
* Return whether a skill may be advertised to and loaded by a human-facing command.
* @param skill - skill metadata carrying optional invocation controls.
* @returns whether the normalized policy permits user invocation.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits user invocation.
*/
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.userInvocable !== false
return skill.invocation.userInvocable
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
@@ -171,7 +176,7 @@ export class SkillService extends Service {
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillRegistration>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private nextProviderOrder = 0
@@ -219,7 +224,7 @@ export class SkillService extends Service {
* 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
* receives a no-op disposer so it cannot remove the winner.
* @param skill - the complete skill definition to expose for discovery.
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
*/
register(skill: SkillRegistration): () => void {
@@ -229,15 +234,20 @@ export class SkillService extends Service {
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
const definition: SkillDefinition = {
...skill,
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },
provider: skill.provider ?? RUNTIME_PROVIDER,
}
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(skill.name, skill)
runtime.set(definition.name, definition)
updateRevision()
invalidateCache()
yield () => {
runtime.delete(skill.name)
runtime.delete(definition.name)
updateRevision()
invalidateCache()
}
@@ -375,19 +385,18 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillRegistration
return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER })
return Promise.resolve(candidate.locator as SkillDefinition)
},
}
function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
return {
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
...skill.invocation !== undefined ? { invocation: skill.invocation } : {},
invocation: skill.invocation,
source: skill.source,
provider: skill.provider ?? RUNTIME_PROVIDER,
provider: skill.provider,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
rank: RUNTIME_RANK,
locator: skill,
@@ -464,7 +473,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...invocation !== undefined ? { invocation } : {},
invocation,
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},

View File

@@ -14,6 +14,7 @@ function memorySkill(name: string, description: string, rank: number, body = `${
return {
name,
description,
invocation: { modelInvocable: true, userInvocable: true },
provider: 'memory',
source: 'memory',
rank,
@@ -57,6 +58,7 @@ describe('SkillService registry', () => {
return [{
name: 'shadowed',
description: 'Higher priority',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'override',
source: 'override',
rank: 5,
@@ -82,6 +84,7 @@ describe('SkillService registry', () => {
return [{
name: 'same-rank-skill',
description: 'Same rank',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'same-rank',
source: 'same-rank',
rank: 10,
@@ -136,9 +139,11 @@ describe('SkillService registry', () => {
const listed = await ctx.skills.list()
expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only'])
expect(listed.find(skill => skill.name === 'both')?.invocation).toEqual({ modelInvocable: true, userInvocable: true })
expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only'])
expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only'])
expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' })
expect((await ctx.skills.get('both'))?.invocation).toEqual({ modelInvocable: true, userInvocable: true })
})
it('validates parsed candidate fields', async () => {
@@ -224,6 +229,7 @@ describe('SkillService registry', () => {
const candidate: SkillCandidate = {
name: 'skill-a',
description: 'Skill A',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'contextual',
source: 'test',
rank: 1,
@@ -258,6 +264,7 @@ describe('SkillService registry', () => {
return [{
name: 'cached-skill',
description: 'Cached skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'cached',
source: 'test',
rank: 1,
@@ -295,6 +302,7 @@ describe('SkillService registry', () => {
resolve({
name: 'held-skill',
description: 'Held skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'held',
source: 'test',
content: 'Held body.',
@@ -307,6 +315,7 @@ describe('SkillService registry', () => {
return [{
name: 'held-skill',
description: 'Held skill',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'held',
source: 'test',
rank: 1,
@@ -481,6 +490,7 @@ describe('SkillService registry', () => {
list: () => Promise.resolve([{
name: skillName,
description: 'Candidate',
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,

View File

@@ -47,7 +47,7 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
// Type import also declaration-merges the optional `sessionPersistence`
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { SkillService, SkillSummary } from '@deepseek-ai/dsh-skill'
import type { SkillService } from '@deepseek-ai/dsh-skill'
// Type import declaration-merges the `userInteraction` service onto `Context`;
// the ask-user-question queue is registered by ./chat/questions.
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -170,10 +170,6 @@ export type {
TuiViewport,
} from './extension/types.ts'
function isSkillUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.userInvocable !== false
}
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
@@ -1076,7 +1072,7 @@ export function createTuiChat(
const loadSkillCommands = (service: SkillService): void => {
service.list({ cwd, signal: skillAbort.signal }).then(
(summaries) => {
const invocable = summaries.filter(isSkillUserInvocable)
const invocable = summaries.filter(skill => skill.invocation.userInvocable)
if (disposed || invocable.length === 0) return
// The argument-hint slot shows in the menu but is never inserted on
// selection, so it carries the skill's scope instead of an
@@ -1277,7 +1273,7 @@ export function createTuiChat(
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
if (!isSkillUserInvocable(summary)) {
if (!summary.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}
@@ -1288,7 +1284,7 @@ export function createTuiChat(
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
if (!isSkillUserInvocable(skill)) {
if (!skill.invocation.userInvocable) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}

View File

@@ -4014,7 +4014,13 @@ describe('skill slash command', () => {
listCalls += 1
if (listCalls === 1) return Promise.resolve<SkillSummary[]>([])
if (listCalls === 2) {
return Promise.resolve<SkillSummary[]>([{ name: 'demo-skill', description: 'demo', source: 'runtime', provider: 'runtime' }])
return Promise.resolve<SkillSummary[]>([{
name: 'demo-skill',
description: 'demo',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
}
return new Promise<SkillSummary[]>((resolve) => { resolvePendingList = resolve })
},
@@ -4031,8 +4037,21 @@ describe('skill slash command', () => {
await tick()
await dispose(result)
resolvePendingList?.([{ name: 'other-skill', description: 'late', source: 'runtime', provider: 'runtime' }])
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
resolvePendingList?.([{
name: 'other-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
}])
pendingGet[0]?.resolve({
name: 'demo-skill',
description: 'late',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'late body',
})
await tick()
expect(result.agent.sent).toEqual([])
expect(result.terminal.output).not.toContain('late body')
@@ -4043,6 +4062,7 @@ describe('renderSkillInvocation', () => {
const skill: SkillDefinition = {
name: 'demo-skill',
description: 'Demo skill',
invocation: { modelInvocable: true, userInvocable: true },
source: 'runtime',
provider: 'runtime',
content: 'Body text.',