From 9ac15473352886ec0e041c313d0e0d19488399b6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 13:10:21 +0800 Subject: [PATCH] fix(web): stop leaking ask_user_question into every preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ui-question`'s node half called `ctx.tools.register` on the host context. `ScopedLayers.merge()` combines the global layer with the agent's exact-scope layer, and an unscoped registration lands in the global one — so the tool reached every agent no matter which preset composed it. `core-web`, sold as a two-tool benchmark surface, really presented three, and a locally authored `bash-only` preset presented two. Rendering a question is a host UI capability; having the tool is an agent capability, and only a preset decides that. The node half is now empty and the `tool-ask-user` row moved into the presets that want it. The TUI keeps its own row, having no presets. The web composition test now asserts the global tool layer is EMPTY, which is the invariant that would have caught this: any tool outside a preset reaches every agent. --- .../agent-presets/cordis/agent.cordis.yml | 3 +++ .../agent-presets/standard/agent.cordis.yml | 3 +++ apps/cli/tests/web-agent-presets.spec.ts | 23 +++++++++-------- docs/config-catalog.md | 2 +- packages/client/ui-question/README.i18n.yaml | 4 +-- packages/client/ui-question/README.md | 2 +- packages/client/ui-question/README.zh.md | 2 +- packages/client/ui-question/package.json | 1 - packages/client/ui-question/src/index.ts | 25 ++++++++----------- .../ui-question/tests/node-plugin.spec.ts | 15 ++++++----- packages/client/ui-question/tsconfig.json | 4 +-- pnpm-lock.yaml | 3 --- 12 files changed, 45 insertions(+), 42 deletions(-) diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index b681c0c699..a69f80895a 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -211,6 +211,9 @@ # ── remaining model-facing rows ───────────────────────────────────────────── +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index b79c0b3975..e029517f91 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -226,6 +226,9 @@ # ── remaining model-facing rows ───────────────────────────────────────────── +- id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 4d2a23bd00..5a9ba88d35 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -70,11 +70,13 @@ beforeAll(async () => { }, 120_000) describe('the shipped Web composition', () => { - it('leaves only the host UI tool in the global layer', () => { - // `ask_user_question` is the host's own interaction surface, not an agent - // capability, so it stays global. Every other tool now belongs to a - // preset; a regression here means an agent-plane row came back to base. - expect(toolNames(ctx)).toEqual(['ask_user_question']) + it('leaves the global tool layer empty', () => { + // Every model-facing tool belongs to a preset, `ask_user_question` + // included: a tool in the global layer reaches EVERY agent regardless of + // which preset composed it, so a two-tool benchmark surface would really + // present three. A regression here means an agent-plane row came back to + // the host composition. + expect(toolNames(ctx)).toEqual([]) }) it('supplies both shipped presets, and only those, from the system root', async () => { @@ -114,7 +116,8 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), }) try { - expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + // Exactly what the preset lists — nothing arrives from the host. + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { await handle.dispose() } @@ -130,14 +133,14 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) await minimal.dispose() // Tearing the minimal session down leaves the full one whole. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) - expect(toolNames(ctx)).toEqual(['ask_user_question']) + expect(toolNames(ctx)).toEqual([]) } finally { await full.dispose() } @@ -351,7 +354,7 @@ describe('authoring a preset on the shipped composition', () => { try { // The same tools the shipped `core-web` composes, from a file written // through the service into a root outside the installed harness. - expect(toolNames(authorCtx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { await handle.dispose() } @@ -388,7 +391,7 @@ describe('the default preset as a user setting', () => { try { // `mount()` with no id resolves the effective default. Two tools, not // `standard`'s catalog: the setting decided the composition. - expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { await handle.dispose() } diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 88a429f14e..7a1f74328f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2491,7 +2491,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index bca51d908b..d7a62af6a4 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -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/client/ui-question/README.md -README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c -README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691 +README.md: 00763e03234468e80dce56d99e6abb719a0d97e3 +README.zh.md: b0dd70264e885c73d7be76add800326e9740ef44 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 72d9439677..d31ceb62c4 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. +Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets). The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 6344327d26..9f9ad01c3f 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 +Web 提问功能插件:其浏览器侧把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent,无论它由哪个 preset 组装,于是一个"两工具"的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。 组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 7416596284..7e129c9731 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -40,7 +40,6 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "clsx": "^2.0.0", "react": "^18.2.0" }, diff --git a/packages/client/ui-question/src/index.ts b/packages/client/ui-question/src/index.ts index 901e832c14..4ceb9e0bd1 100644 --- a/packages/client/ui-question/src/index.ts +++ b/packages/client/ui-question/src/index.ts @@ -1,17 +1,14 @@ /** - * Web question plugin, node half: enabling this UI feature also exposes the - * model-facing ask_user_question tool on the host composition. + * Web question plugin, node half. + * + * Deliberately empty. Mounting `ask_user_question` here put it in the tools + * registry's GLOBAL layer, so every agent saw it no matter which preset + * composed it — a two-tool benchmark preset actually presented three, and a + * locally authored `bash-only` preset presented two. Rendering a question is + * a host UI capability; having the tool is an agent capability, and only a + * preset decides that. The `tool-ask-user` row belongs in the presets that + * want it (and in the TUI composition, which has no presets). */ -import type { Context } from 'cordis' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -/** Host services required by the model-facing tool. */ -export const inject = ['tools', 'userInteraction'] - -/** - * Mount ask_user_question for hosts that selected the Web question plugin. - * @param ctx - Host plugin context carrying tools and userInteraction. - */ -export function apply(ctx: Context): void { - toolAskUser.apply(ctx) -} +/** Host plugin body — the model-facing tool is composed per preset, not here. */ +export function apply(): void {} diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 9bc34e9599..4602ef0bed 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { apply, inject } from '../src/index.ts' +import { apply } from '../src/index.ts' let ctx: Context | undefined @@ -13,16 +13,19 @@ afterEach(async () => { }) describe('ui-question node plugin', () => { - it('exposes ask_user_question only for the selected Web feature lifecycle', async () => { + it('mounts no model-facing tool', async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(UserInteractionService) - const feature = ctx.plugin({ inject: [...inject], apply }) - await feature.await() - expect(ctx.tools.get('ask_user_question')).toBeDefined() - await feature.dispose() + await ctx.plugin({ apply }).await() + + // Selecting the Web question FEATURE must not hand every agent the tool. + // `ctx.tools.register` on an unscoped host context files into the global + // layer, which merges into every agent's view regardless of the preset + // that composed it — so a two-tool benchmark preset would really present + // three. The `tool-ask-user` row belongs to the presets that want it. expect(ctx.tools.get('ask_user_question')).toBeUndefined() }) }) diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json index 6b5b0acc3a..72d4e96b0f 100644 --- a/packages/client/ui-question/tsconfig.json +++ b/packages/client/ui-question/tsconfig.json @@ -29,9 +29,7 @@ { "path": "../ui-slots" }, - { - "path": "../../ui/tool-ask-user" - }, + { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b068a990f..c1ccd303c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1917,9 +1917,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../ui/tool-ask-user clsx: specifier: ^2.0.0 version: 2.1.1