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.

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 preset that wants it. The TUI keeps its own
row, having no presets.

The composition tests now assert the global tool layer is EMPTY, which is the
invariant that would have caught this: any tool outside a preset reaches every
agent. The browser lane's composition, seeded-history, and hermetic-skill
assertions address their registries through a composed agent for the same
reason — those services are per session now, and the host cannot resolve an
`isolate` realm by name.
This commit is contained in:
Yichen Jiang
2026-08-07 02:52:41 +08:00
parent 2d1df8a9e2
commit 89ae94d89f
13 changed files with 89 additions and 53 deletions

View File

@@ -222,6 +222,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

@@ -54,11 +54,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 () => {
@@ -98,7 +100,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()
}
@@ -114,14 +117,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()
}

View File

@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
@@ -170,10 +171,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
// The meter belongs to an agent's preset, not to the process — token
// accounting is per session. It is used here as a pure pricing function
// over fixture content, so a throwaway composition is enough to reach one.
const priced = await scaffold.ctx.agents.create({
sessionId: SessionId('seeded-history-pricing'),
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
let realizedWithCompaction: string
try {
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
} finally {
await priced.dispose()
}
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -220,11 +233,17 @@ describe('web e2e: seeded history renders through cold resume', () => {
const projections = body.result.value?.projections
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
// The seed carries a session/title event: the title unit must serve it.
// The seed carries a session/title event: the title unit is host-plane, so
// it folds the detached log and serves the value with nothing composed.
expect(typeof projections?.values.title).toBe('string')
// tool-todo is composed but the seed has no todo/write: whole-value null,
// key PRESENT (absence would mean the unit never registered).
expect(projections?.values).toHaveProperty('todos', null)
// `todos` is NOT here, and that is the contract rather than a gap. Its unit
// is registered by `tool-todo` inside an agent's preset, so a detached
// session yields it from exactly one place: a durable checkpoint written
// while the session was live. This seed was written straight to persistence
// and never ran, so it recorded none — and the answer no longer depends on
// whether some UNRELATED session happens to be composed right now, which is
// the whole reason the checkpoint row carries its own view.
expect(projections?.values).not.toHaveProperty('todos')
})
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {

View File

@@ -11,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
@@ -63,11 +65,26 @@ afterEach(async () => {
it('assembles the shipped Web catalog with the confined access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
const ctx = scaffold.ctx
// The catalog belongs to an AGENT, not to the process: every model-facing row
// now lives in a preset mounted under one session's scope, so the global
// layer holds nothing and a caller must name the agent to see anything. This
// composes from the deployment default — what a session that names no preset
// gets — which is the shape this test has always been about.
expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([])
const handle = await ctx.agents.create({
sessionId: SessionId('shipped-composition'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
} finally {
await handle.dispose()
}
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and

View File

@@ -2529,7 +2529,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: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42
README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a

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

@@ -1956,9 +1956,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