fix(web): stop leaking ask_user_question into every preset

`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.
This commit is contained in:
Yichen Jiang
2026-08-04 13:10:21 +08:00
parent 6595301afc
commit 9ac1547335
12 changed files with 45 additions and 42 deletions

View File

@@ -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'

View File

@@ -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'

View File

@@ -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()
}

View File

@@ -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))

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/client/ui-question/README.md
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691
README.md: 00763e03234468e80dce56d99e6abb719a0d97e3
README.zh.md: b0dd70264e885c73d7be76add800326e9740ef44

View File

@@ -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`.

View File

@@ -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` 拒绝整个等待。

View File

@@ -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"
},

View File

@@ -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 {}

View File

@@ -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()
})
})

View File

@@ -29,9 +29,7 @@
{
"path": "../ui-slots"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../support/invariants"
}

3
pnpm-lock.yaml generated
View File

@@ -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