From f1d09ad4d1a5b30bfc7bbb84851d5fe34fcc24c8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 02:33:53 +0800 Subject: [PATCH 1/3] fix(web): keep host-plane services out of the preset, and pin the lane's skill roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the agent plane behind per-session presets took five rows with it that the host still owns, and the Web surface stopped booting: `host-apiproxy` injects `subagents`, so with the registry disabled here the entry never activated and `dsh web` died at plugin-tree load. The criterion is injection, not subject matter. A host row that injects a service resolves it before any session exists, so there is no agent to key by: `bash-env` (which `apps/cli/src/web.ts` injects to publish `DSH_WEB_URL`), the `subagents` registry and its spawn/fork backends (a process singleton whose cross-session queries the api-proxy serves, and whose provider names are globally unique), and `tool-subagent-report` (a continuable setup on that singleton, registered once per live session by a list that is not scope-aware) all stay host-plane. What a preset chooses is which delegation TOOLS it sees. The browser lane needs the second half: skill roots now resolve inside a preset, a subtree the lane's include patches cannot reach, so the row's documented environment fallback is pinned for the whole scaffold lifetime — presets mount when a session is created, not at boot. Without it a developer's real ~/.dsh/skills enters replay requests and goldens while CI sees none. --- .../agent-presets/core-web/agent.cordis.yml | 20 ++----- .../agent-presets/standard/agent.cordis.yml | 58 ++++++++----------- apps/web/tests/scaffold.ts | 27 +++++++++ packages/bundle/web-app/cordis.patch.yml | 28 +++++---- 4 files changed, 72 insertions(+), 61 deletions(-) diff --git a/apps/cli/config/agent-presets/core-web/agent.cordis.yml b/apps/cli/config/agent-presets/core-web/agent.cordis.yml index 9a251461c5..e9003aa975 100644 --- a/apps/cli/config/agent-presets/core-web/agent.cordis.yml +++ b/apps/cli/config/agent-presets/core-web/agent.cordis.yml @@ -15,20 +15,12 @@ text: >- You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. -# `tool-bash` provides the `bashEnv` service, so it needs a realm even alone. -- id: shell - name: cordis:group - group: true - isolate: - bashEnv: true - config: - # The registry and its consumer share the realm: a consumer left outside - # would resolve the host's `bashEnv`, which this plane no longer provides. - - id: bash-env - name: '@deepseek-ai/dsh-bash-env' - - - id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership. `tool-bash` consumes that host +# registry from here. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 407e4d7d27..0a211bc97f 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -31,22 +31,15 @@ # ── shell ─────────────────────────────────────────────────────────────────── -# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a -# realm like any other provider. The executor behind it (`bash-sandbox`) stays -# in the host composition, where the sandbox policy owns it. -- id: shell - name: cordis:group - group: true - isolate: - bashEnv: true - config: - # The registry and its consumer share the realm: a consumer left outside - # would resolve the host's `bashEnv`, which this plane no longer provides. - - id: bash-env - name: '@deepseek-ai/dsh-bash-env' - - - id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# never reached the model's shell at all. `tool-bash` consumes the host registry +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' # ── filesystem ────────────────────────────────────────────────────────────── @@ -172,29 +165,21 @@ # ── delegation and workflows ──────────────────────────────────────────────── -# Every backend and every tool that reaches `subagents` or `workflows` shares -# one realm: a consumer left outside it would resolve the host's registry -# instead, which this preset does not populate. +# The `subagents` registry and its spawn/fork backends live in the HOST +# composition: the registry is a process singleton whose cross-session queries +# the api-proxy serves to the browser, and a provider name may only be +# registered once. This preset contributes the delegation TOOLS, which resolve +# that host registry. +# +# `workflows` is different — nothing outside an agent reads it — so every row +# that reaches it shares one entry-local realm here, and a consumer left +# outside would resolve a host registry this preset does not populate. - id: delegation name: cordis:group group: true isolate: - subagents: true workflows: true config: - - id: subagent - name: '@deepseek-ai/dsh-subagent' - - - id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - - - id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - - id: tool-subagent-control name: '@deepseek-ai/dsh-tool-subagent-control' @@ -215,8 +200,11 @@ toolName: subagent_fork backgroundMode: continuable - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' + # `tool-subagent-report` is host-plane for the same reason as the registry, + # not because a preset may not want it: it registers a CONTINUABLE SETUP on + # that singleton rather than a tool this agent calls, and the setup list is + # not scope-aware — one copy per mounted preset means every child gets + # `report` registered once per live session, which throws on the second. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8d8f866740..b1c6d66bf9 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -240,6 +240,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise [key, process.env[key]]), + ) + let skillRootEnvironmentRestored = false + const restoreSkillRootEnvironment = (): void => { + if (skillRootEnvironmentRestored) return + skillRootEnvironmentRestored = true + for (const [key, value] of Object.entries(originalSkillRootEnvironment)) { + if (value === undefined) Reflect.deleteProperty(process.env, key) + else process.env[key] = value + } + } + Object.assign(process.env, skillRootEnvironment) let persistenceRoot: string try { persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) @@ -415,6 +440,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) { throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') } @@ -462,6 +488,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 356d0a95a7..2fe73e7dd5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -204,8 +204,11 @@ # absent from a surface overlay would silently reappear the day someone reorders # the composition. -- id: bash-env - disabled: true +# `bash-env` STAYS in the host plane: `apps/cli/src/web.ts` injects it to +# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is +# the criterion for host-plane ownership — injection resolves before any session +# exists, so there is no agent to key by. Behind a preset realm those variables +# would never reach the model's shell at all. - id: tool-bash disabled: true @@ -261,14 +264,12 @@ - id: tool-result-prune disabled: true -- id: subagent - disabled: true - -- id: subagent-spawn - disabled: true - -- id: subagent-fork - disabled: true +# The subagent registry and its backends STAY in the host plane. `subagents` is +# a process singleton with a cross-session query surface (`listChildren`, +# `followup`) that the host api-proxy serves to the browser, and a provider +# registers under a globally unique name, so a per-session copy would both +# starve that host row and collide on the second session. What a preset +# chooses is which delegation TOOLS its agent sees, below. - id: tool-subagent-control disabled: true @@ -282,8 +283,11 @@ - id: tool-subagent-fork disabled: true -- id: tool-subagent-report - disabled: true +# `tool-subagent-report` is host-plane for the same reason as the registry, not +# because a preset may not want it: it registers a CONTINUABLE SETUP on that +# singleton rather than a tool this agent calls, and the setup list is not +# scope-aware — one copy per mounted preset means every child gets `report` +# registered once per live session, which throws on the second. - id: workflow-workerthread disabled: true From 2d1df8a9e210409044851668047456c71a8010d4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 02:51:09 +0800 Subject: [PATCH 2/3] fix(cli): declare the packages the shipped presets name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle split emptied `apps/cli`'s plugin dependencies, and the flat module fallback links only that manifest's closure — so a preset row naming `@deepseek-ai/dsh-persona` resolved to nothing and every preset mount failed, leaving each session with an agent that had no tools, persona, or token meter. Which packages the shipped presets compose is not implied by any bundle: the presets live beside this manifest, so this is where their closure is declared. The hermetic skill test now addresses the registry through the composed agent, the only shape that can see a preset's `isolate` realm. --- apps/cli/package.json | 27 +++++++++ apps/web/tests/scaffold-hermetic.e2e.ts | 26 ++++++-- pnpm-lock.yaml | 81 +++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..f859e55914 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,17 +19,44 @@ "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", "js-yaml": "^4.2.0", diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index 6e14eebfa5..8b7c2ccea3 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type {} from '@deepseek-ai/dsh-skill' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' import { launchWebScaffold, type WebScaffold } from './scaffold.ts' async function writeSkill(root: string, name: string): Promise { @@ -37,10 +39,26 @@ it('isolates replay skill discovery from every ambient host root', async () => { let scaffold: WebScaffold | undefined try { scaffold = await launchWebScaffold() - const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) - expect(names).not.toContain('ambient-dsh') - expect(names).not.toContain('ambient-agents') - expect(names).not.toContain('ambient-bundled') + const ctx = scaffold.ctx + // The skill registry belongs to one agent's preset, behind an `isolate` + // realm the host cannot resolve by name — so the roots under test are only + // reachable through a composed agent, which is also the only shape that + // ever asks. `serviceFor` is the same addressing the gateway's `skill.list` + // uses for a browser request about a session. + const handle = await ctx.agents.create({ + sessionId: SessionId('hermetic-skills'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const skills = ctx.agentPresets.serviceFor(handle.agent, 'skills') + if (skills === undefined) throw new Error('composed agent mounts no skill registry') + const names = (await skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name) + expect(names).not.toContain('ambient-dsh') + expect(names).not.toContain('ambient-agents') + expect(names).not.toContain('ambient-bundled') + } finally { + await handle.dispose() + } } finally { try { await scaffold?.close() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2ce2523a8..2dc5cbd047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,24 @@ importers: '@deepseek-ai/dsh-base': specifier: workspace:^ version: link:../../packages/bundle/base + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless @@ -155,6 +173,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty @@ -164,21 +188,78 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../packages/tasks/tasks-local '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../packages/llm/token-meter '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ version: link:../../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../packages/tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../packages/context/workspace-context commander: specifier: ^15.0.0 version: 15.0.0 From 89ae94d89f7b316cb576a3a74e614d288db7c49a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 02:52:41 +0800 Subject: [PATCH 3/3] 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. 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. --- .../agent-presets/standard/agent.cordis.yml | 3 ++ apps/cli/tests/web-agent-presets.spec.ts | 19 +++++----- apps/web/tests/seeded-history.e2e.ts | 35 ++++++++++++++----- apps/web/tests/shipped-composition.e2e.ts | 27 +++++++++++--- 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 -- 13 files changed, 89 insertions(+), 53 deletions(-) diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 0a211bc97f..ec4f71a849 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -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' diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 4c2d7484ee..80f397d916 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -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() } diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index f9ef1055d1..c91f654440 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 782279f074..a0aaf3429e 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index be8c2a4949..1b3426925c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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)) diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index bca51d908b..a00cd9bb55 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: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42 +README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a 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 2dc5cbd047..0b861518a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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