Преглед изворни кода

fix(web): return every service a host row injects, and run the browser lane

The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.

`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.

`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.

The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.

Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.

Goldens re-recorded for the hero's preset chip and the settings nav entry.
Yichen Jiang пре 1 месец
родитељ
комит
d247c50c6f
28 измењених фајлова са 225 додато и 127 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  2. 15 19
      apps/cli/config/agent-presets/cordis/agent.cordis.yml
  3. 9 14
      apps/cli/config/agent-presets/minimal/agent.cordis.yml
  4. 15 19
      apps/cli/config/agent-presets/standard/agent.cordis.yml
  5. 7 1
      apps/cli/tests/web-agent-presets.spec.ts
  6. 22 4
      apps/web/tests/scaffold-hermetic.e2e.ts
  7. 27 0
      apps/web/tests/scaffold.ts
  8. 27 8
      apps/web/tests/seeded-history.e2e.ts
  9. 22 5
      apps/web/tests/shipped-composition.e2e.ts
  10. 4 0
      apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
  11. 4 0
      apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
  12. 2 0
      apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
  13. 3 0
      apps/web/tests/snapshots/models-settings/configured.expected.md
  14. 3 0
      apps/web/tests/snapshots/models-settings/empty.expected.md
  15. 3 0
      apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md
  16. 7 0
      apps/web/tests/snapshots/settings-chrome/dialog.expected.md
  17. 25 25
      docs/cordis-catalog/services.md
  18. 5 2
      docs/module-graph.md
  19. 0 5
      packages/bundle/web-app/cordis.patch.yml
  20. 2 2
      packages/client/README.i18n.yaml
  21. 2 2
      packages/client/connection/README.i18n.yaml
  22. 2 2
      packages/client/ui-agent-preset/README.i18n.yaml
  23. 2 2
      packages/client/ui-question/README.i18n.yaml
  24. 7 7
      packages/cordis/tool-cordis/src/api-catalog.ts
  25. 2 2
      packages/host/apiproxy/README.i18n.yaml
  26. 2 2
      packages/preset/README.i18n.yaml
  27. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  28. 2 2
      packages/session-persistence/session-persistence-jsonl/README.i18n.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 51f1d6790eb763e6ad2f534fb2e1137ecbcba1f2
-2026-08-03-per-session-agent-presets.zh.md: c1881c372880e56adc74a46e20c5ec9bd34b36aa
+2026-08-03-per-session-agent-presets.md: b0f0de131b6f5051bb5a17a38636826ebd5771fd
+2026-08-03-per-session-agent-presets.zh.md: 9ca1374d1b4b43fda24fea81adf741d474dc0084

+ 15 - 19
apps/cli/config/agent-presets/cordis/agent.cordis.yml

@@ -33,22 +33,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 ──────────────────────────────────────────────────────────────
 
@@ -166,6 +159,12 @@
 # `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.
+#
+# `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: delegation
   name: cordis:group
   group: true
@@ -192,9 +191,6 @@
         toolName: subagent_fork
         backgroundMode: continuable
 
-    - id: tool-subagent-report
-      name: '@deepseek-ai/dsh-tool-subagent-report'
-
     - id: workflow-workerthread
       name: '@deepseek-ai/dsh-workflow-workerthread'
       config:

+ 9 - 14
apps/cli/config/agent-presets/minimal/agent.cordis.yml

@@ -15,20 +15,15 @@
     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 — 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'
 
 - id: tool-str-replace-editor
   name: '@deepseek-ai/dsh-tool-str-replace-editor'

+ 15 - 19
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 ──────────────────────────────────────────────────────────────
 
@@ -181,6 +174,12 @@
 # `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.
+#
+# `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: delegation
   name: cordis:group
   group: true
@@ -207,9 +206,6 @@
         toolName: subagent_fork
         backgroundMode: continuable
 
-    - id: tool-subagent-report
-      name: '@deepseek-ai/dsh-tool-subagent-report'
-
     - id: workflow-workerthread
       name: '@deepseek-ai/dsh-workflow-workerthread'
       config:

+ 7 - 1
apps/cli/tests/web-agent-presets.spec.ts

@@ -1,7 +1,7 @@
 import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { Context } from 'cordis'
 import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
 import { SessionId } from '@deepseek-ai/dsh-session'
@@ -22,6 +22,7 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
  * agent's capabilities is the real thing, including both shipped presets.
  */
 async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
+  const storageRoot = join(dirname(settingsFile), 'storages')
   const patches: PatchOptions[] = [
     ...loadOverlayPatches('dsh-test', WEB_OVERLAY),
     // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
@@ -30,6 +31,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
     // outcome. Point it at a temp file for the same reason the roster below
     // names only the shipped root.
     { id: 'settings', config: { path: settingsFile, watch: false } },
+    // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
+    // file writes the developer's own `~/.dsh/storages/` — and then reads it
+    // back on the next run, so a stored document from any other build decides
+    // this test's boot. Same reason the settings row above is pinned.
+    { id: 'storage-json', config: { root: storageRoot } },
     // Host rows with side effects outside this process: a bound port, a served
     // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
     // stay ENABLED on purpose — the api-proxy is the host row that injects

+ 22 - 4
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<void> {
@@ -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()

+ 27 - 0
apps/web/tests/scaffold.ts

@@ -249,6 +249,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   // paths at load, and an in-process boot must NEVER touch the developer's
   // real ~/.dsh document or credential file.
   const harnessHome = join(workspaceCwd, '.dsh-home')
+  // Skill discovery is model-visible input, and its roots now resolve inside a
+  // PRESET — a subtree this lane's include patches cannot reach, because the
+  // roster mounts it directly per session rather than as a row of the booted
+  // tree. The row's documented fallback is the environment, so pin that: the
+  // whole scaffold lifetime, not just the boot, since presets mount when a
+  // session is created. Without this a developer's real ~/.dsh/skills silently
+  // enters replay requests and goldens while CI sees none.
+  const skillRootEnvironment = {
+    DSH_HOME: join(workspaceCwd, '.dsh-home'),
+    DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
+    DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
+  }
+  const originalSkillRootEnvironment = Object.fromEntries(
+    Object.keys(skillRootEnvironment).map(key => [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-'))
@@ -418,6 +443,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     if (process.cwd() !== originalCwd) process.chdir(originalCwd)
     const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
     restoreCredentialEnvironment()
+    restoreSkillRootEnvironment()
     if (cleanupFailures.length > 0) {
       throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
     }
@@ -465,6 +491,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
       } finally {
         restoreCredentialEnvironment()
+        restoreSkillRootEnvironment()
       }
       if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
     },

+ 27 - 8
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 () => {

+ 22 - 5
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

+ 4 - 0
apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md

@@ -25,6 +25,10 @@
   - img
   - text: workspace
   - img
+- button "标准模式":
+  - img
+  - text: 标准模式
+  - img
 - textbox "Describe what you want to build"
 - button "Commands":
   - img

+ 4 - 0
apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md

@@ -25,6 +25,10 @@
   - img
   - text: workspace
   - img
+- button "标准模式":
+  - img
+  - text: 标准模式
+  - img
 - textbox "Describe what you want to build"
 - button "Commands":
   - img

+ 2 - 0
apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md

@@ -1,6 +1,8 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reply with the single word" [disabled]
+  - img
+  - text: 标准模式
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 3 - 0
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -7,6 +7,9 @@
     - button "模型":
       - img
       - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
   - button "打开配置文件"
   - button "关闭":
     - img

+ 3 - 0
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -7,6 +7,9 @@
     - button "模型":
       - img
       - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
   - button "打开配置文件"
   - button "关闭":
     - img

+ 3 - 0
apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md

@@ -7,6 +7,9 @@
     - button "模型":
       - img
       - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
   - button "打开配置文件"
   - button "关闭":
     - img

+ 7 - 0
apps/web/tests/snapshots/settings-chrome/dialog.expected.md

@@ -7,10 +7,17 @@
     - button "模型":
       - img
       - text: 模型
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
   - button "打开配置文件"
   - button "关闭":
     - img
     - text: 关闭
+  - text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的 preset。
+  - button "standard":
+    - text: standard
+    - img
   - text: 权限 选择新会话的默认权限模式
   - button "Workspace Write":
     - text: Workspace Write

+ 25 - 25
docs/cordis-catalog/services.md

@@ -50,51 +50,51 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-
 
 Registry over the deployment's agent presets.
 
-Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read.
+Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read.
 
 ```ts cordis-catalog
 /**
- * Every profile the configured roots currently supply.
- * @returns the profiles, first-root-wins per id.
+ * Every preset the configured roots currently supply.
+ * @returns the presets, first-root-wins per id.
  */
 async list(): Promise<AgentPreset[]>
 
 /**
- * Resolve one profile by id.
- * @param id - the profile id, or `undefined` for {@link defaultId}.
- * @returns the resolved profile.
+ * Resolve one preset by id.
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
+ * @returns the resolved preset.
  * @throws when no configured root supplies that id.
  */
 async resolve(id?: string): Promise<AgentPreset>
 
 /**
- * Compose one agent from a profile, installing it under that agent alone.
+ * Compose one agent from a preset, installing it under that agent alone.
  *
  * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls
- * the agent creation back, so a broken profile never yields a half-composed
+ * the agent creation back, so a broken preset never yields a half-composed
  * session.
  * @param agentCtx - the agent's scope context.
- * @param id - the profile id, or `undefined` for {@link defaultId}.
- * @returns the profile that was mounted, for the caller to record.
- * @throws when the profile is unknown or its composition is unusable.
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
+ * @returns the preset that was mounted, for the caller to record.
+ * @throws when the preset is unknown or its composition is unusable.
  */
 async mount(agentCtx: Context, id?: string): Promise<AgentPreset>
 
 /**
- * Read one profile's composition text.
- * @param id - the profile id.
+ * Read one preset's composition text.
+ * @param id - the preset id.
  * @returns the composition exactly as stored.
  * @throws when no configured root supplies that id.
  */
 async read(id: string): Promise<string>
 
 /**
- * Create or replace a locally authored profile.
+ * Create or replace a locally authored preset.
  *
  * The text is shape-checked before it lands, so a save cannot leave a file no
  * session could load; it is NOT mounted, so a composition that parses but
  * names a missing plugin still fails at the next session that selects it.
- * @param id - the profile id, which becomes its directory name.
+ * @param id - the preset id, which becomes its directory name.
  * @param content - the composition text.
  * @param metadata - display name and description; clearing both removes the file.
  * @throws when the id is unusable, the text is not an entry list, or the
@@ -103,16 +103,16 @@ async read(id: string): Promise<string>
 async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void>
 
 /**
- * Delete a locally authored profile.
- * @param id - the profile id.
- * @throws when the profile is unknown or ships with the deployment.
+ * Delete a locally authored preset.
+ * @param id - the preset id.
+ * @throws when the preset is unknown or ships with the deployment.
  */
 async remove(id: string): Promise<void>
 
 /**
- * One agent's instance of a service its profile mounted.
+ * One agent's instance of a service its preset mounted.
  *
- * A profile publishes services behind `isolate` realms, which are invisible
+ * A preset publishes services behind `isolate` realms, which are invisible
  * outside the group that declares them — including to the host. This is how a
  * caller holding the agent reads one anyway: a request that is ABOUT a
  * session but arrives from outside it, which is every browser RPC.
@@ -121,8 +121,8 @@ async remove(id: string): Promise<void>
  * because injection resolves before any session exists and has no agent to
  * key by; such a service belongs on the host plane instead.
  * @param agent - the agent whose composition to look inside.
- * @param name - the service name as the profile's rows resolve it.
- * @returns the agent's instance, or undefined when its profile mounts none.
+ * @param name - the service name as the preset's rows resolve it.
+ * @returns the agent's instance, or undefined when its preset mounts none.
  */
 serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined
 
@@ -138,9 +138,9 @@ serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K):
  * therefore restores the previous composition rather than leaving the agent
  * with nothing.
  * @param agentCtx - the agent's scope context.
- * @param id - the profile to compose the agent from instead.
- * @returns the profile now installed.
- * @throws when the profile is unknown or its composition is unusable; the
+ * @param id - the preset to compose the agent from instead.
+ * @returns the preset now installed.
+ * @throws when the preset is unknown or its composition is unusable; the
  * previous composition is restored first.
  */
 async recompose(agentCtx: Context, id: string): Promise<AgentPreset>

+ 5 - 2
docs/module-graph.md

@@ -485,6 +485,7 @@ flowchart TD
   pkg_lsp_local --> pkg_lsp
   pkg_lsp_local --> pkg_subprocess
   pkg_lsp_local --> pkg_timeout
+  pkg_agent_presets --> pkg_atomic_write
   pkg_agent_presets --> pkg_invariants
   pkg_agent_presets --> pkg_paths
   pkg_agent_presets --> pkg_scope
@@ -850,7 +851,9 @@ flowchart TD
   pkg_client_ui_agent_preset --> pkg_client_runtime
   pkg_client_ui_agent_preset --> pkg_client_ui_conversation
   pkg_client_ui_agent_preset --> pkg_client_ui_primitives
+  pkg_client_ui_agent_preset --> pkg_client_ui_settings
   pkg_client_ui_agent_preset --> pkg_client_ui_slots
+  pkg_client_ui_agent_preset --> pkg_client_web_react
   pkg_client_ui_agent_preset --> pkg_invariants
   pkg_client_ui_command --> pkg_client_connection
   pkg_client_ui_command --> pkg_client_locale
@@ -1191,7 +1194,7 @@ flowchart TD
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) |
 | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -1264,7 +1267,7 @@ flowchart TD
 | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
 | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |

+ 0 - 5
packages/bundle/web-app/cordis.patch.yml

@@ -206,8 +206,6 @@
 # absent from a surface overlay would silently reappear the day someone reorders
 # the composition.
 
-- id: bash-env
-  disabled: true
 
 - id: tool-bash
   disabled: true
@@ -282,9 +280,6 @@
 - id: tool-subagent-fork
   disabled: true
 
-- id: tool-subagent-report
-  disabled: true
-
 - id: workflow-workerthread
   disabled: true
 

+ 2 - 2
packages/client/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/README.md
-README.md: a0888b0d02d3d6ed234489d686ae9ad996e11674
-README.zh.md: a6e4e42c12c97802897c6d58efdcc7468d45db8c
+README.md: c2400ce36dc0df5494cdf69ec9fe34c71c2b65b9
+README.zh.md: b88c28df776c1a74f9b652282756b614229e0ff5

+ 2 - 2
packages/client/connection/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/connection/README.md
-README.md: 526df44ce2a167e6f06bedea6d57e4d703848e89
-README.zh.md: 02bba4aeed7155ebacae2ad66b4325d069f37301
+README.md: 0392d51a929e2b92d9cd4b4728549250437631f1
+README.zh.md: e82b25bf30717abbcd9ec460b5790277d3f9ce13

+ 2 - 2
packages/client/ui-agent-preset/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-agent-preset/README.md
-README.md: 3df8e43892ea619155918b28a171d30b6f9d1a3d
-README.zh.md: 4725517c6f2ebf5b172bd01023cdef0de6f542ac
+README.md: 6261984835a70b6b669545acc8f9bfafc93ab64d
+README.zh.md: fb860812b1646f6b1912e4aedf56b00696957352

+ 2 - 2
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: 00763e03234468e80dce56d99e6abb719a0d97e3
-README.zh.md: b0dd70264e885c73d7be76add800326e9740ef44
+README.md: d31ceb62c46cb7a720b52d9e2a6c92e98d1c7e42
+README.zh.md: 9f9ad01c3f1f661f60fe11ec072f487cb18c170a

+ 7 - 7
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -98,15 +98,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async read(id: string): Promise<string>',
-        jsDoc: '/**\n * Read one profile\'s composition text.\n * @param id - the profile id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */',
+        jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */',
       },
       {
         signature: 'async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void>',
-        jsDoc: '/**\n * Create or replace a locally authored profile.\n *\n * The text is shape-checked before it lands, so a save cannot leave a file no\n * session could load; it is NOT mounted, so a composition that parses but\n * names a missing plugin still fails at the next session that selects it.\n * @param id - the profile id, which becomes its directory name.\n * @param content - the composition text.\n * @param metadata - display name and description; clearing both removes the file.\n * @throws when the id is unusable, the text is not an entry list, or the\n * deployment configures no writable root.\n */',
+        jsDoc: '/**\n * Create or replace a locally authored preset.\n *\n * The text is shape-checked before it lands, so a save cannot leave a file no\n * session could load; it is NOT mounted, so a composition that parses but\n * names a missing plugin still fails at the next session that selects it.\n * @param id - the preset id, which becomes its directory name.\n * @param content - the composition text.\n * @param metadata - display name and description; clearing both removes the file.\n * @throws when the id is unusable, the text is not an entry list, or the\n * deployment configures no writable root.\n */',
       },
       {
         signature: 'async remove(id: string): Promise<void>',
-        jsDoc: '/**\n * Delete a locally authored profile.\n * @param id - the profile id.\n * @throws when the profile is unknown or ships with the deployment.\n */',
+        jsDoc: '/**\n * Delete a locally authored preset.\n * @param id - the preset id.\n * @throws when the preset is unknown or ships with the deployment.\n */',
       },
       {
         signature: 'serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined',
@@ -2227,6 +2227,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PrepareSessionOptions',
     declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n    readonly seedSource?: undefined;\n}) | RestoredSessionOptions;',
   },
+  {
+    name: 'PresetMetadata',
+    declaration: 'export interface PresetMetadata {\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n}',
+  },
   {
     name: 'PresetOption',
     declaration: 'export interface PresetOption {\n    value: string;\n    name: string;\n    description?: string;\n}',
@@ -2235,10 +2239,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PresetSpec',
     declaration: 'export interface PresetSpec {\n    sandbox: SandboxMode;\n    approval: ApprovalPolicy;\n    name?: string;\n    description?: string;\n}',
   },
-  {
-    name: 'PresetMetadata',
-    declaration: 'export interface PresetMetadata {\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n}',
-  },
   {
     name: 'PresetTrust',
     declaration: 'export type PresetTrust = \'system\' | \'user\';',

+ 2 - 2
packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: abada8172e0ddc74850e1b3a0ec6b19aee5b8011
-README.zh.md: 833ebf603da94ecd46dec5f8ea8c22b71d164859
+README.md: 2715a3115307017a06ac13f35efd50c239036923
+README.zh.md: 597c1fe2df6ce97385e16c6120e62fdfe0ab090d

+ 2 - 2
packages/preset/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/preset/README.md
-README.md: 997e64c75e36d6a70438a92c223dea93eab5eb32
-README.zh.md: 946b98a7b1366facec37360c9f39ca36cc908872
+README.md: ca874398f06728af60999d11f03b67fb2f4b62e0
+README.zh.md: 2187bc78ed2ad2dffcc989c587992e3aeda3c5ea

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: b6f00366ef319637d19cccb513d41dbff1eaa5e1
-README.zh.md: f09fd89c2c9bfb08fafbdb47b80ebaf26ae42b47
+README.md: c61a84730a273052eeab31dc24696a7340621d4f
+README.zh.md: 1caacec4f93990865e82c4498e64578eb149a3d6

+ 2 - 2
packages/session-persistence/session-persistence-jsonl/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/session-persistence/session-persistence-jsonl/README.md
-README.md: 7cd3b36f40e00e37c161ebb30eaae95f86337203
-README.zh.md: a7395c94eb8adcddf30b3b8452c5426d57ae3a4e
+README.md: 79bc4cebe6f303a5aa8b73a60dfee55dcb9a90a7
+README.zh.md: 9bd4d34f2983c60d5df2d5bab85913618d2e6d66