Merge latest master into codex/migrate-to-oxlint
This commit is contained in:
@@ -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/ui/tui/README.md
|
||||
README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d
|
||||
README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b
|
||||
README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4
|
||||
README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82
|
||||
|
||||
@@ -26,7 +26,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists user-invocable skills, and exact invocation rejects a skill whose user policy disables it.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
@@ -139,7 +139,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出用户可调用的 skill,按精确名称调用时也会拒绝用户策略禁用的 skill。
|
||||
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
|
||||
@@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -1043,11 +1043,12 @@ export function createTuiChat(
|
||||
requestRender()
|
||||
}
|
||||
|
||||
// Skill listing is async while `createTuiChat` is synchronous, so the
|
||||
// completions rebuild once the catalog resolves. Disabled-for-model skills
|
||||
// are absent from `list()`, so they never appear as completions; a user can
|
||||
// still invoke one by typing its exact name.
|
||||
// Skill listing is async while `createTuiChat` is synchronous, so the TUI
|
||||
// retains the last complete invocation-neutral catalog for synchronous
|
||||
// editor completion, filters it for user invocation, and refreshes it after
|
||||
// registry invalidation.
|
||||
let skillCommands: SlashCommand[] = []
|
||||
let skillCommandScan = 0
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
const base = new CombinedAutocompleteProvider(
|
||||
[
|
||||
@@ -1068,24 +1069,37 @@ export function createTuiChat(
|
||||
agent,
|
||||
))
|
||||
}
|
||||
const refreshVisibleSlashAutocomplete = (): void => {
|
||||
const cursor = editor.getCursor()
|
||||
const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col)
|
||||
if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) {
|
||||
// pi-tui's provider setter closes an existing menu but does not query
|
||||
// the replacement for the current draft. Tab in a slash-name context
|
||||
// only requests suggestions, so it refreshes without editing the text.
|
||||
editor.handleInput('\t')
|
||||
}
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
|
||||
const loadSkillCommands = (service: SkillService): void => {
|
||||
service.list({ cwd, signal: skillAbort.signal }).then(
|
||||
(summaries) => {
|
||||
if (disposed || summaries.length === 0) return
|
||||
const refreshSkillCommands = (service: SkillService): void => {
|
||||
const scan = ++skillCommandScan
|
||||
service.snapshot({ cwd, signal: skillAbort.signal }).then(
|
||||
(snapshot) => {
|
||||
if (disposed || scan !== skillCommandScan || !snapshot.complete) return
|
||||
const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable)
|
||||
// The argument-hint slot shows in the menu but is never inserted on
|
||||
// selection, so it carries the skill's scope instead of an
|
||||
// instructions placeholder. `SkillSource` is open-ended; every
|
||||
// non-project source (user, custom, bundled, runtime, …) collapses
|
||||
// to `(user)`.
|
||||
skillCommands = summaries.map(skill => ({
|
||||
skillCommands = invocable.map(skill => ({
|
||||
name: `skill:${skill.name}`,
|
||||
description: skill.description,
|
||||
argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)',
|
||||
}))
|
||||
refreshCommandAutocomplete()
|
||||
refreshVisibleSlashAutocomplete()
|
||||
requestRender()
|
||||
},
|
||||
() => {
|
||||
@@ -1094,7 +1108,10 @@ export function createTuiChat(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (skills !== undefined) loadSkillCommands(skills)
|
||||
const disposeSkillChanges = skills === undefined
|
||||
? () => {}
|
||||
: ctx.on('skills/change', () => { refreshSkillCommands(skills) })
|
||||
if (skills !== undefined) refreshSkillCommands(skills)
|
||||
|
||||
// The agent scope is minted by agent-loop and intentionally inherits only
|
||||
// that core plugin's dependencies. A child command producer declares its own
|
||||
@@ -1261,19 +1278,40 @@ export function createTuiChat(
|
||||
appendNotice('Skills are not available in this session.', 'warning')
|
||||
return
|
||||
}
|
||||
skills.get(name, { cwd, signal: skillAbort.signal }).then(
|
||||
(skill) => {
|
||||
const lookup = { cwd, signal: skillAbort.signal }
|
||||
const reportFailure = (error: unknown): void => {
|
||||
if (disposed) return
|
||||
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
skills.list(lookup).then(
|
||||
(summaries) => {
|
||||
if (disposed) return
|
||||
if (skill === undefined) {
|
||||
const summary = summaries.find(skill => skill.name === name)
|
||||
if (summary === undefined) {
|
||||
appendNotice(`Unknown skill: ${name}`, 'warning')
|
||||
return
|
||||
}
|
||||
deliver(renderSkillInvocation(skill, instructions))
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (disposed) return
|
||||
appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
|
||||
if (!summary.invocation.userInvocable) {
|
||||
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
|
||||
return
|
||||
}
|
||||
skills.get(name, lookup).then(
|
||||
(skill) => {
|
||||
if (disposed) return
|
||||
if (skill === undefined) {
|
||||
appendNotice(`Unknown skill: ${name}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (!skill.invocation.userInvocable) {
|
||||
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
|
||||
return
|
||||
}
|
||||
deliver(renderSkillInvocation(skill, instructions))
|
||||
},
|
||||
reportFailure,
|
||||
)
|
||||
},
|
||||
reportFailure,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1488,6 +1526,7 @@ export function createTuiChat(
|
||||
fileSearch.dispose()
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
disposeSkillChanges()
|
||||
disposePromptChanges()
|
||||
for (const value of promptValues) value.dispose()
|
||||
stopBannerReveal()
|
||||
|
||||
@@ -18,7 +18,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -3828,10 +3828,30 @@ describe('skill slash command', () => {
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
|
||||
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
|
||||
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
|
||||
skills.register({
|
||||
name: 'user-only-skill',
|
||||
description: 'User-only skill',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'runtime',
|
||||
content: 'User-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Model-only instructions body.',
|
||||
})
|
||||
skills.register({
|
||||
name: 'trusted-only-skill',
|
||||
description: 'Trusted-only skill',
|
||||
invocation: { modelInvocable: false, userInvocable: false },
|
||||
source: 'runtime',
|
||||
content: 'Trusted-only instructions body.',
|
||||
})
|
||||
}
|
||||
|
||||
it('labels slash completions by scope and hides model-disabled skills', async () => {
|
||||
it('labels slash completions by scope and applies user invocation policy', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill')
|
||||
await tick()
|
||||
@@ -3839,8 +3859,139 @@ describe('skill slash command', () => {
|
||||
expect(result.terminal.output).toContain('(user)')
|
||||
expect(result.terminal.output).toContain('project-skill')
|
||||
expect(result.terminal.output).toContain('(project)')
|
||||
expect(result.terminal.output).toContain('user-only-skill')
|
||||
expect(result.terminal.output).not.toContain('[instructions]')
|
||||
expect(result.terminal.output).not.toContain('hidden-skill')
|
||||
expect(result.terminal.output).not.toContain('model-only-skill')
|
||||
expect(result.terminal.output).not.toContain('trusted-only-skill')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('refreshes slash completions after runtime skill additions and complete removals', async () => {
|
||||
let skills: SkillService | undefined
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
},
|
||||
})
|
||||
if (skills === undefined) throw new Error('skills service not mounted')
|
||||
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
const disposeSkill = skills.register({
|
||||
name: 'dynamic-skill',
|
||||
description: 'DYNAMIC_COMPLETION_MARKER',
|
||||
source: 'runtime',
|
||||
content: 'Dynamic body.',
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
|
||||
result.terminal.send('\x03')
|
||||
disposeSkill()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:dynamic')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('retains last-good slash completions across incomplete snapshots', async () => {
|
||||
let skills: SkillService | undefined
|
||||
let provider: SkillProvider | undefined
|
||||
let invalidate = (): void => {}
|
||||
let fail = false
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
await ctx.plugin(SkillService)
|
||||
skills = ctx.get('skills')
|
||||
provider = {
|
||||
name: 'flaky-completion',
|
||||
async list() {
|
||||
if (fail) throw new Error('transient completion failure')
|
||||
return [{
|
||||
name: 'stable-skill',
|
||||
description: 'STABLE_COMPLETION_MARKER',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'test',
|
||||
provider: 'flaky-completion',
|
||||
rank: 1,
|
||||
locator: 'stable',
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
skills?.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider as SkillProvider
|
||||
})
|
||||
},
|
||||
})
|
||||
if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted')
|
||||
|
||||
fail = true
|
||||
invalidate()
|
||||
await tick()
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:stable')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => {
|
||||
const pendingSnapshots: Array<PromiseWithResolvers<SkillCatalogSnapshot>> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => {
|
||||
const pending = Promise.withResolvers<SkillCatalogSnapshot>()
|
||||
pendingSnapshots.push(pending)
|
||||
return pending.promise
|
||||
},
|
||||
get: () => Promise.resolve(undefined),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
|
||||
result.ctx.emit('skills/change')
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(3)
|
||||
pendingSnapshots[2]?.resolve({
|
||||
skills: [{
|
||||
name: 'latest-skill',
|
||||
description: 'LATEST_COMPLETION_MARKER',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
pendingSnapshots[0]?.resolve({
|
||||
skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
pendingSnapshots[1]?.resolve({
|
||||
skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }],
|
||||
complete: true,
|
||||
})
|
||||
await tick()
|
||||
|
||||
result.terminal.output = ''
|
||||
result.terminal.send('/skill:latest')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER')
|
||||
expect(result.terminal.output).not.toContain('STALE_FIRST')
|
||||
expect(result.terminal.output).not.toContain('STALE_SECOND')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -3859,12 +4010,62 @@ describe('skill slash command', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('invokes a model-disabled skill by its exact name', async () => {
|
||||
it('invokes a user-only skill by its exact name', async () => {
|
||||
const result = await setup({ configureContext: withSkills })
|
||||
result.terminal.send('/skill:hidden-skill')
|
||||
result.terminal.send('/skill:user-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="user-only-skill">\nUser-only instructions body.\n</skill>' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('checks user policy before loading and rechecks the loaded definition', async () => {
|
||||
const summaries: SkillSummary[] = [
|
||||
{
|
||||
name: 'model-only-skill',
|
||||
description: 'Model-only skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
},
|
||||
{
|
||||
name: 'policy-race-skill',
|
||||
description: 'Policy race skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
},
|
||||
]
|
||||
const get = vi.fn((name: string) => Promise.resolve<SkillDefinition | undefined>({
|
||||
name,
|
||||
description: 'Policy race skill',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Instructions must not be delivered.',
|
||||
}))
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => Promise.resolve({ skills: summaries, complete: true }),
|
||||
list: () => Promise.resolve(summaries),
|
||||
get,
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/skill:model-only-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:policy-race-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(get).toHaveBeenCalledTimes(1)
|
||||
expect(get).toHaveBeenCalledWith('policy-race-skill', expect.objectContaining({ cwd: '/workspace' }))
|
||||
expect(result.terminal.output).toContain('Skill "model-only-skill" is not available for user invocation.')
|
||||
expect(result.terminal.output).toContain('Skill "policy-race-skill" is not available for user invocation.')
|
||||
expect(result.terminal.output).not.toContain('Instructions must not be delivered.')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -3919,6 +4120,7 @@ describe('skill slash command', () => {
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
snapshot: () => Promise.reject(new Error('list boom')),
|
||||
list: () => Promise.reject(new Error('list boom')),
|
||||
get: () => Promise.reject(new Error('get boom')),
|
||||
} as never)
|
||||
@@ -3928,37 +4130,84 @@ describe('skill slash command', () => {
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('failed to load')
|
||||
expect(result.terminal.output).toContain('get boom')
|
||||
expect(result.terminal.output).toContain('list boom')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops skill list and lookup results that settle after disposal', async () => {
|
||||
const pendingList: Array<(value: SkillSummary[]) => void> = []
|
||||
let listCalls = 0
|
||||
let resolvePendingList: ((value: SkillSummary[]) => void) | undefined
|
||||
const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = []
|
||||
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
|
||||
const result = await setup({
|
||||
configureContext: async (ctx) => {
|
||||
ctx.provide('tools', { get() { return undefined } } as never)
|
||||
ctx.provide('skills', {
|
||||
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
|
||||
snapshot: () => new Promise<SkillCatalogSnapshot>((resolve) => { pendingSnapshots.push(resolve) }),
|
||||
list: () => {
|
||||
listCalls += 1
|
||||
if (listCalls === 1 || listCalls === 2) {
|
||||
const name = listCalls === 1 ? 'demo-skill' : 'error-skill'
|
||||
return Promise.resolve<SkillSummary[]>([{
|
||||
name,
|
||||
description: 'demo',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}])
|
||||
}
|
||||
return new Promise<SkillSummary[]>((resolve) => { resolvePendingList = resolve })
|
||||
},
|
||||
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('/skill:demo-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:error-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('/skill:other-skill')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
await dispose(result)
|
||||
|
||||
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
|
||||
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
|
||||
result.ctx.emit('skills/change')
|
||||
expect(pendingSnapshots).toHaveLength(1)
|
||||
for (const resolve of pendingSnapshots) {
|
||||
resolve({
|
||||
skills: [{
|
||||
name: 'late',
|
||||
description: 'late',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
}],
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
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',
|
||||
})
|
||||
pendingGet[1]?.reject(new Error('late failure'))
|
||||
await tick()
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(result.terminal.output).not.toContain('late failure')
|
||||
expect(result.terminal.output).not.toContain('late body')
|
||||
expect(result.terminal.output).not.toContain('late failure')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3966,6 +4215,7 @@ describe('renderSkillInvocation', () => {
|
||||
const skill: SkillDefinition = {
|
||||
name: 'demo-skill',
|
||||
description: 'Demo skill',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Body text.',
|
||||
|
||||
Reference in New Issue
Block a user