Merge pull request #6275 from deepseek-harness/fix/skill-menu-plugin-change

fix(ui-skill): 开关插件后刷新 `/` 菜单的 skill 列表
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-16 10:38:32 +08:00
commit e412760f94
11 files changed
+153 -20

No files matched your search

@@ -0,0 +1,98 @@
// Web e2e scenario: switching a plugin on the Plugins page updates the `/`
// menu of a Session that is already open. One chromium page holds a blank
// Session while a second page switches the official badge skill bundle; a
// global skill seeded in the harness home stays listed throughout. No model
// call is issued, so a stray stream fails loud on the open LLM seam.
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Locator, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, writeComposerDraft } from './support.ts'
const BUNDLE_DIR = fileURLToPath(new URL('../../../packages/experimental/badge-skill-bundle', import.meta.url))
const BUNDLE = '@deepseek-ai/dsh-experimental-badge-skill-bundle'
const GLOBAL_SKILL = 'dsh-global-skill'
describe('web e2e: the slash menu follows plugin switches', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let plugins: Page
let toggle: Locator
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ profile: { packages: [{ dir: BUNDLE_DIR }] } })
// The scaffold pins the skill-filesystem harness home inside its temporary world.
const directory = join(scaffold.workspaceCwd, '.dsh-home', 'skills', GLOBAL_SKILL)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), [
'---',
`name: ${GLOBAL_SKILL}`,
'description: Listed whichever plugins are switched on',
'---',
'',
`# ${GLOBAL_SKILL}`,
'',
].join('\n'))
browser = await chromium.launch()
page = await newEnglishPage(browser)
plugins = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await plugins.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await plugins.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
const panel = plugins.locator('[data-plugin-panel]')
await panel.getByRole('button', { name: 'More', exact: true }).click()
toggle = panel.locator(`[data-plugin-package="${BUNDLE}"]`).getByRole('switch')
await toggle.waitFor({ timeout: 10_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Retype the query in the open Session and read whether the badge skill is listed.
* @returns whether the menu lists `dsh-badge` beside the global skill.
*/
async function badgeListed(): Promise<boolean> {
await writeComposerDraft(page, page.locator('[data-composer-input]').first(), '/dsh-')
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
// The skill source answers with one list, so the global entry marks a settled skill group.
await menu.getByRole('option', { name: new RegExp(`^${GLOBAL_SKILL} `) }).waitFor({ timeout: 10_000 })
return await menu.getByRole('option', { name: /^dsh-badge / }).count() === 1
}
/**
* Switch the badge bundle on the second page and wait for the switch to settle.
* @param enabled - the requested bundle state.
*/
async function switchBundle(enabled: boolean): Promise<void> {
expect(await toggle.getAttribute('aria-checked')).toBe(String(!enabled))
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(String(enabled))
}
it('lists a switched bundle\'s skill only while the bundle is on, keeping the global skill', async () => {
onTestFailed(async () => {
await saveFailureShot(page, 'web-e2e-skill-menu-plugin-change')
await saveFailureShot(plugins, 'web-e2e-skill-menu-plugin-change-plugins')
})
// The open Session cached its catalog while the bundle was off.
expect(await badgeListed()).toBe(false)
// Each switch re-reads the catalog of the Session that stayed open.
for (const enabled of [true, false, true]) {
await switchBundle(enabled)
await expect.poll(badgeListed, { timeout: 10_000 }).toBe(enabled)
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
+1
View File
@@ -171,6 +171,7 @@
"tests/queue-image.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/skill-user-invoke.e2e.ts",
"tests/skill-menu-plugin-change.e2e.ts",
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/agent-preset-selection.e2e.ts",
+6 -6
View File
@@ -21,14 +21,14 @@
en: 2ba1a6158c85b46e
zh: 9ab58ceef8743849
/deepseek-ai-dsh-client-ui-skill/use-this-package/the-skill-tool-row:
en: 975c6cd80af3684a
zh: 395c10e51c6a15f6
en: 270bc37a226ed247
zh: 15a1c4c82c3a1f00
/deepseek-ai-dsh-client-ui-skill/understand-the-implementation:
en: df2a0ee9d2638f41
zh: 3b4af7e505e990ca
/deepseek-ai-dsh-client-ui-skill/understand-the-implementation/candidate-flow:
en: 1a02a85859f851c7
zh: 03bd48b83c4a287c
en: e8921ef18728794c
zh: dabc89ee5186d8c6
/deepseek-ai-dsh-client-ui-skill/understand-the-implementation/registration:
en: 4b06a66c56d19ff9
zh: d9c8d118f73d89bb
@@ -51,8 +51,8 @@
en: da53c649077dad7e
zh: 845f77793f8e10a2
/deepseek-ai-dsh-client-ui-skill/known-limitations-and-deferred-work:
en: aed53b17a91d4b02
zh: fac571e9228e69c5
en: 3c5dd1c12e5a5d61
zh: 19e27163cb94dbc1
/deepseek-ai-dsh-client-ui-skill/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+5 -3
View File
@@ -37,7 +37,7 @@ The `preparing` stage shows only the skill icon and title, with no requested ski
A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry one left-to-right highlight across the title, separator, and skill name, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Text and icons are tertiary at rest and secondary on hover; error and interruption text retains its semantic color.
Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, plugin manager operations, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
-----
@@ -51,9 +51,9 @@ The source implements no adjudication hooks and no reference codec: the pick lan
### Candidate flow
Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. The list RPC rides the plugin's root-context connection captured at registration; draft chip visuals derive from the `lexicon` scan.
Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), the forwarded `plugin-manager/changed` event clears everything (a manager operation may add or remove skill providers that every session reads), and `connection/reset` clears everything. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. The list RPC rides the plugin's root-context connection captured at registration; draft chip visuals derive from the `lexicon` scan.
Each catalog fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `skills/list`. A temporary `skillCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC; preset invalidation, connection reset, and plugin disposal also cancel a pending history wait.
Each catalog fetch requires an existing retained Client Session and waits for its initial history open to succeed before sending `skills/list`. A temporary `skillCatalog` reference holds that Session until the fetch settles. An unretained Session or a failed open rejects without sending the RPC; preset invalidation, plugin manager operations, connection reset, and plugin disposal also cancel a pending history wait.
### Registration
@@ -104,6 +104,8 @@ These limits define where the reference and the row fall back to generic behavio
- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it.
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload exists on the prompt wire.
- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache.
- **Catalog changes without a forwarded event keep cached catalogs** — an HMR reload after a CLI or hand edit announces no `plugin-manager/changed` event, and adding or removing a `SKILL.md` on disk forwards no event at all, so an open session lists its previous skills until a preset switch, plugin manager operation, or connection reset.
- **Draft skill highlighting waits for the next catalog fetch** — a preset switch, plugin manager operation, or connection reset empties the session's lexicon, so with the menu closed an existing `/name` in the draft loses its skill highlight until the `/` menu fetches the catalog again; an open menu fetches it immediately.
<a id="dev-note"></a>
### Dev Note
+5 -3
View File
@@ -37,7 +37,7 @@ kind: "package-reference"
收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用以同一道高光从左到右扫过标题、分隔符与 skill 名称,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 文字和图标默认使用 tertiary 色,悬停使用 secondary 色;错误和中断文字保留各自的语义色。
鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、插件管理操作、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
-----
@@ -51,9 +51,9 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面
### 候选流程
目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。列表 RPC 使用插件注册时捕获的根上下文连接;草稿 chip 视觉由 `lexicon` 扫描派生。
目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),转发的 `plugin-manager/changed` 事件清空全部缓存(插件管理操作可能增删每个会话都会读取的 skill provider),`connection/reset` 清空全部缓存。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。列表 RPC 使用插件注册时捕获的根上下文连接;草稿 chip 视觉由 `lexicon` 扫描派生。
每次目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `skills/list`。临时的 `skillCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC;预设失效、连接重置和插件释放也会取消正在等待的历史打开。
每次目录拉取都要求客户端已持有该会话,并等待首次历史打开成功后才发送 `skills/list`。临时的 `skillCatalog` 引用持续持有会话,直到拉取结束。未被持有的会话或打开失败会直接拒绝,不发送 RPC;预设失效、插件管理操作、连接重置和插件释放也会取消正在等待的历史打开。
### 注册
@@ -104,6 +104,8 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面
- **仅含工具结果的 history 页使用通用行**:键控分派要求配对的工具调用位于运行时窗口内;分页将工具调用留在窗口外时,工具结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议约定。
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;提示词协议上没有 occurrence 身份、位置跟踪或结构化引用载荷。
- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。
- **没有转发事件的目录变更保留已缓存的目录**:CLI 或手动编辑后由 HMR 应用的重载不发出 `plugin-manager/changed` 事件,在磁盘上增删 `SKILL.md` 也不会转发任何事件,已打开的会话会继续列出之前的 skill,直到切换预设、执行插件管理操作或重置连接。
- **草稿中的 skill 高亮等待下一次目录拉取**:切换预设、插件管理操作或重置连接会清空该会话的 lexicon;菜单关闭时,草稿里已有的 `/name` 会失去 skill 高亮,直到 `/` 菜单重新拉取目录;菜单打开时会立即重新拉取。
<a id="dev-note"></a>
### 开发备注
+1
View File
@@ -61,6 +61,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-client-ui-tool": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@testing-library/react": "^16.3.3",
"@deepseek-ai/cordis": "workspace:~",
+13 -6
View File
@@ -19,12 +19,13 @@
* snapshot locally, so one session costs one RPC. The scope-birth warm hook
* prewarms the session's key; a preset switch drops that one key (the
* catalog is the preset's, and a blank session may switch after the warm);
* connection/reset clears everything — the host
* catalog may differ across generations. A shared in-flight fetch
* deliberately outlives any single menu interaction: closing the menu must
* not kill the prewarm other consumers will hit, so it carries its own
* abort (fired only on invalidation/teardown) while a candidates caller
* with an aborted signal just returns early.
* plugin-manager/changed clears everything — a manager operation may add or
* remove providers any session's catalog reads; connection/reset clears
* everything — the host catalog may differ across generations. A shared
* in-flight fetch deliberately outlives any single menu interaction: closing
* the menu must not kill the prewarm other consumers will hit, so it carries
* its own abort (fired only on invalidation/teardown) while a candidates
* caller with an aborted signal just returns early.
* Catalog RPCs retain an existing Client Session through completion and
* wait for its initial history open to succeed before contacting the Host.
*
@@ -36,6 +37,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: the forwarded `plugin-manager/changed` declaration.
import type {} from '@deepseek-ai/dsh-plugin-manager/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
@@ -246,6 +249,10 @@ export function apply(ctx: ClientContext): void {
// A preset decides which skill providers an agent reads, so a switched
// session's cached catalog belongs to the composition it no longer runs.
ctx.remote.$on('agent-preset/selected', invalidate)
// The Host announces every manager operation after its reload without
// naming what changed, and a switched plugin may register providers in the
// global layer every session reads, so each one drops every cached catalog.
ctx.remote.$on('plugin-manager/changed', clearAll)
ctx.on('connection/reset', clearAll)
ctx.effect(() => {
const unregister = inputTriggers.registerSource(source)
@@ -5,7 +5,8 @@
* the source behavior contract driven directly on the captured source with
* real ClientSessionContext projections — sessionId addressing, the
* session-keyed catalog cache (single-flight per key, scope-birth warm
* prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure
* prewarm, plugin-manager/changed and connection/reset clears), shared fuzzy
* name ranking, RPC-failure
* rejection, pick → plain-text outcome (the plain-text-reference decision:
* .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md),
* the synchronous
@@ -375,6 +376,19 @@ describe('catalog cache', () => {
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(4)
})
it('plugin-manager/changed clears every cached session', async () => {
const { list, payloads } = countingList()
const { source, remote } = await bench(list)
await source.candidates(proj('s1'), req(''))
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(2)
// A switched plugin may register into the global layer every session reads.
remote.emit('plugin-manager/changed', [{ reason: 'bundle' }])
await source.candidates(proj('s1'), req(''))
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(4)
})
})
describe('lexicon', () => {
@@ -536,7 +550,7 @@ describe('reference preview', () => {
expect(list.mock.calls.map(([payload]) => payload)).toEqual([{ sessionId: 'first' }, { sessionId: 'second' }])
})
it.each(['reset', 'preset', 'dispose'] as const)('cancels a pending preview on %s even if the RPC completes late', async (reason) => {
it.each(['reset', 'preset', 'plugin', 'dispose'] as const)('cancels a pending preview on %s even if the RPC completes late', async (reason) => {
const gate = Promise.withResolvers<ListResult>()
const { ctx, source, remote, fiber, openResource } = await bench(() => gate.promise)
const session = proj('preview')
@@ -546,6 +560,7 @@ describe('reference preview', () => {
const completion = expect(source.candidates(session, req(''))).rejects.toThrow()
if (reason === 'reset') ctx.emit('connection/reset')
else if (reason === 'preset') remote.emit('agent-preset/selected', [session.sessionId, 'minimal'])
else if (reason === 'plugin') remote.emit('plugin-manager/changed', [{ reason: 'plugin' }])
else await fiber.dispose()
expect(listener).toHaveBeenCalledTimes(1)
gate.resolve({ ok: true, value: { skills: rows } })
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../boot/plugin-manager"
},
{
"path": "../../core/session"
},
+3
View File
@@ -5986,6 +5986,9 @@ importers:
'@deepseek-ai/dsh-client-ui-tool':
specifier: workspace:*
version: link:../ui-tool
'@deepseek-ai/dsh-plugin-manager':
specifier: workspace:*
version: link:../../boot/plugin-manager
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
+1
View File
@@ -161,6 +161,7 @@
"apps/web/tests/queue-image.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/skill-user-invoke.e2e.ts",
"apps/web/tests/skill-menu-plugin-change.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",
"apps/web/tests/access-confirmation.e2e.ts",
"apps/web/tests/agent-preset-selection.e2e.ts",