diff --git a/apps/web/tests/skill-menu-plugin-change.e2e.ts b/apps/web/tests/skill-menu-plugin-change.e2e.ts new file mode 100644 index 0000000000..4d64c14b0b --- /dev/null +++ b/apps/web/tests/skill-menu-plugin-change.e2e.ts @@ -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 + + 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 { + 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 { + 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([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ff56fc9485..cc40149d20 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 8d82e8e675..88c72545c3 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -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: cf53f93101c7ef11 + zh: e27ec720dad7b75d /deepseek-ai-dsh-client-ui-skill/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 4340f3f78e..94ef9a1465 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -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,7 @@ 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. +- **Profile changes outside the plugin manager keep cached catalogs** — an HMR reload after a CLI or hand edit announces no `plugin-manager/changed` event, so an open session lists its previous skills until a preset switch or connection reset. ### Dev Note diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index d18c52f30d..208f8756fb 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -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,7 @@ source 不实现任何裁决钩子,也没有引用 codec:pick 落下字面 - **仅含工具结果的 history 页使用通用行**:键控分派要求配对的工具调用位于运行时窗口内;分页将工具调用留在窗口外时,工具结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议约定。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;提示词协议上没有 occurrence 身份、位置跟踪或结构化引用载荷。 - **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。 +- **插件管理器之外的 profile 变更保留已缓存的目录**:CLI 或手动编辑后由 HMR 应用的重载不发出 `plugin-manager/changed` 事件,已打开的会话会继续列出之前的 skill,直到切换预设或重置连接。 ### 开发备注 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index ba6f20647f..049120d7f1 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -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:~", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index ee67b91e4c..2de10d2ccb 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -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,9 @@ 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 a manager operation after its reload, and the plugins + // it switched may register providers in the global layer every session reads. + ctx.remote.$on('plugin-manager/changed', clearAll) ctx.on('connection/reset', clearAll) ctx.effect(() => { const unregister = inputTriggers.registerSource(source) diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index fa997d86b4..b426da40fd 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -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() 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 } }) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 276da77105..cf3c43a1cd 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/session-controller/tsconfig.client.json" }, + { + "path": "../../boot/plugin-manager" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5de58cc2e..680692b9d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5983,6 +5983,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 diff --git a/tsconfig.host.json b/tsconfig.host.json index 90cb6d8b0b..715adae96f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",