Просмотр исходного кода

feat(web): use Messages for the default DeepSeek provider

Yichen Jiang 1 неделя назад
Родитель
Сommit
c258d3cf4e
38 измененных файлов с 316 добавлено и 148 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  2. 3 3
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  3. 3 3
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  4. 80 0
      apps/web/tests/deepseek-messages-chat.e2e.ts
  5. 35 22
      apps/web/tests/deepseek-messages-settings.e2e.ts
  6. 1 8
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  7. 2 7
      apps/web/tests/expected/deepseek-messages-settings/picker.expected.md
  8. 3 3
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  9. 1 1
      apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md
  10. 2 2
      apps/web/tests/onboarding-usable-provider.e2e.ts
  11. 20 14
      apps/web/tests/scaffold.ts
  12. 4 4
      apps/web/tests/shipped-composition.e2e.ts
  13. 37 45
      apps/web/tests/smoke-real.e2e.ts
  14. 1 0
      apps/web/tsconfig.json
  15. 2 2
      packages/bundle/web-app/README.i18n.yaml
  16. 3 1
      packages/bundle/web-app/README.md
  17. 3 1
      packages/bundle/web-app/README.zh.md
  18. 9 2
      packages/bundle/web-app/cordis.patch.yml
  19. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  20. 1 1
      packages/client/ui-settings-models/README.md
  21. 1 1
      packages/client/ui-settings-models/README.zh.md
  22. 3 3
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
  23. 1 1
      packages/client/ui-settings-models/src/client/index.ts
  24. 2 2
      packages/client/ui-settings-models/src/client/store.ts
  25. 3 3
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  26. 5 5
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  27. 2 2
      packages/client/ui-settings-models/tests/readiness.client.spec.ts
  28. 1 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  29. 2 2
      packages/llm/llm-deepseek-messages/README.i18n.yaml
  30. 1 1
      packages/llm/llm-deepseek-messages/README.md
  31. 1 1
      packages/llm/llm-deepseek-messages/README.zh.md
  32. 1 1
      packages/llm/llm-deepseek-messages/src/adapter.ts
  33. 1 1
      packages/llm/llm-deepseek-messages/src/index.ts
  34. 1 1
      packages/llm/llm-deepseek-messages/tests/adapter.spec.ts
  35. 16 0
      snapshots/web/deepseek-messages-chat/session.v2.jsonl
  36. 7 0
      snapshots/web/deepseek-messages-chat/snapshot.yml
  37. 53 0
      snapshots/web/deepseek-messages-chat/ui.expected.md
  38. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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/feature/2026-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: 75a68ebcda03ea2c5850ad38c5124f81c6e9577f
-2026-09-07-deepseek-messages-adapter.zh.md: c0a091e8d0b18cf90fa50d49dec48fc0500f7400
+2026-09-07-deepseek-messages-adapter.md: f0fcaf96264bd70648c59cfd3de05c1edbda40aa
+2026-09-07-deepseek-messages-adapter.zh.md: 4cb2ef79eb8596955f76d4bda024926c3870c33d

+ 3 - 3
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -18,7 +18,7 @@ Assistant blocks remain the durable model-visible content. A versioned `ReplayEn
 
 Image requests use bounded inline base64 versions from the attachment service. Shared attachment offload and DeepSeek token measurement keep request and measurement policy consistent. Files uploads remain outside this adapter because their endpoints and cache ownership differ from chat-completions; adding them requires a Messages-specific lifetime and error policy.
 
-The Web profile mounts both direct adapters and leaves its selected default unchanged. Its Messages row uses `DEEPSEEK_MESSAGES_API_KEY`, so the two configuration cards can store different credentials. The shared DeepSeek editor dispatches writes by settings namespace and displays the appropriate protocol root; model identity remains provider-qualified in the composer.
+The Web profile selects the Messages adapter and disables the Chat Completions adapter. Both its settings card and model group display DeepSeek, while the provider id remains `deepseek-messages` and settings remain under `llm-deepseek-messages`. First-run onboarding targets that route and reuses `DEEPSEEK_API_KEY`. Separating display names from provider ids preserves explicit protocol selection and recorded request identity. Saved selections remain user-owned; the composer switches them without rewriting Session history.
 
 ## Alternatives considered
 
@@ -30,6 +30,6 @@ The Web profile mounts both direct adapters and leaves its selected default unch
 
 ## Consequences
 
-The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Default compositions retain their selected provider.
+The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. The shared base keeps Chat Completions; the Web overlay owns the Messages default.
 
-Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
+Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.

+ 3 - 3
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。
 
-Web profile 同时挂载两个直接适配器,保留已选择的默认模型。Messages 行使用 `DEEPSEEK_MESSAGES_API_KEY`,使两张配置卡片可以存储不同凭据。共享 DeepSeek 编辑器按设置命名空间写入,并展示相应协议根地址;输入框中的模型标识仍包含提供方
+Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配置卡片和模型分组均显示 DeepSeek,提供方 ID 保持为 `deepseek-messages`,设置仍位于 `llm-deepseek-messages`。首次启动引导面向该路由并复用 `DEEPSEEK_API_KEY`。显示名称与提供方 ID 分离,使协议选择和已记录的请求标识保持明确。已保存的选择仍由用户控制,输入框可以切换它们而不改写 Session 历史
 
 ## 考虑过的替代方案
 
@@ -30,6 +30,6 @@ Web profile 同时挂载两个直接适配器,保留已选择的默认模型
 
 ## 结果
 
-该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。默认组合保留已选择的提供者
+该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 保留 Chat Completions,Web 覆盖层拥有 Messages 默认值
 
-验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
+验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。

+ 80 - 0
apps/web/tests/deepseek-messages-chat.e2e.ts

@@ -0,0 +1,80 @@
+/** Recorded Web conversation through the shipped Messages route and DeepSeek model group. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/deepseek-messages-chat', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
+const MODE = webSnapshotMode()
+const RECORD_PROMPT = '只回复 MESSAGES_WEB_READY,不调用工具。'
+
+describe('web e2e: DeepSeek Messages conversation', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      deepSeekMessages: true,
+      ...(MODE === 'record' ? {} : {
+        replayFixture: FIXTURE,
+        paceMs: 5,
+        replayProviders: [{
+          id: 'deepseek-messages', name: 'DeepSeek',
+          models: [{
+            id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash',
+            contextWindow: 1_000_000, defaultMaxTokens: 256_000,
+            reasoningEfforts: ['off', 'low', 'high', 'max'], defaultReasoningEffort: 'high',
+          }],
+        }],
+      }),
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    try { await browser?.close() } finally { await scaffold?.close() }
+  })
+
+  it('records the Messages provider while displaying DeepSeek in the model selector', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-chat'))
+    const prompts = MODE === 'record' ? [RECORD_PROMPT] : fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
+    expect(prompts).toHaveLength(1)
+    expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByText('DeepSeek', { exact: true }).waitFor()
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    const input = page.locator('[data-composer-input]').first()
+    const settled = scaffold.whenTurnSettled()
+    await input.fill(prompts[0]!)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.sessions.get(sessionId)!
+    expect(session.requestHeader()?.config.provider).toBe('deepseek-messages')
+    await page.getByText('MESSAGES_WEB_READY', { exact: true }).waitFor()
+    if (MODE === 'record') {
+      await recordFixture(scaffold, sessionId, FIXTURE)
+    } else {
+      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'),
+        await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('keeps the recorded-session inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md'])
+  })
+})

+ 35 - 22
apps/web/tests/deepseek-messages-settings.e2e.ts

@@ -1,9 +1,9 @@
-/** Web composition: both DeepSeek protocols keep independent settings, credentials, and model identities. */
+/** Shipped Web Messages defaults, credential reuse, and recovery from a saved Chat Completions selection. */
 import { readFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { chromium, type Browser, type Page } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
   captureStableAria, compareOrRefreshGolden, launchWebScaffold,
   watchConsole, webSnapshotMode, type WebScaffold,
@@ -12,16 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s
 
 const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
 
-describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coexistence', () => {
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages default', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
 
   beforeAll(async () => {
-    vi.stubEnv('DEEPSEEK_MESSAGES_API_KEY', undefined)
-    vi.stubEnv('DEEPSEEK_MESSAGES_BASE_URL', undefined)
-    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
     browser = await chromium.launch()
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
     tripwire = watchConsole(page)
@@ -33,18 +31,21 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
     try {
       await browser?.close()
     } finally {
-      try { await scaffold?.close() } finally { vi.unstubAllEnvs() }
+      await scaffold?.close()
     }
   })
 
-  it('saves each protocol independently and exposes both model catalogs', async () => {
+  it('offers one DeepSeek card and saves Messages settings using the existing credential reference', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings'))
+    expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-messages', name: 'DeepSeek' })
+    expect(scaffold.ctx.llm.listProviders().some(provider => provider.id === 'deepseek-official')).toBe(false)
+    expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置', exact: true })
     await dialog.getByRole('button', { name: '模型', exact: true }).click()
-    await dialog.getByText('DeepSeek Messages', { exact: true }).waitFor()
-    // Unconfigured composition routes appear as setup cards together.
-    const messages = dialog.getByText('DeepSeek Messages', { exact: true }).locator('..').locator('..')
+    await dialog.getByText('DeepSeek', { exact: true }).waitFor()
+    expect(await dialog.getByText('DeepSeek', { exact: true }).count()).toBe(1)
+    const messages = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..')
     await messages.getByText('自定义设置', { exact: true }).click()
     expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
       .toBe('https://api.deepseek.com/anthropic')
@@ -54,22 +55,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
     await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic')
     await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash')
     await messages.getByRole('button', { name: '保存', exact: true }).click()
-    await dialog.getByText('已保存 DeepSeek Messages (deepseek-messages)。', { exact: true }).waitFor()
-    await dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }).click()
-    const original = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..')
-    await original.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-completions')
-    await original.getByText('自定义设置', { exact: true }).click()
-    await original.getByLabel('API 地址', { exact: true }).fill('https://completions.example/v1')
-    await original.getByRole('button', { name: '保存', exact: true }).click()
-    await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
+    await dialog.getByText('已保存 DeepSeek (deepseek-messages)。', { exact: true }).waitFor()
 
     const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(settings).toContain('https://messages.example/anthropic')
-    expect(settings).toContain('https://completions.example/v1')
+    expect(settings).toContain('llm-deepseek-messages:')
+    expect(settings).not.toContain('llm-deepseek:')
     expect(settings).not.toContain('sk-e2e-')
     const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
-    expect(credentials).toContain('DEEPSEEK_MESSAGES_API_KEY: sk-e2e-messages')
-    expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-completions')
+    expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
+    expect(credentials).not.toContain('DEEPSEEK_MESSAGES_API_KEY')
     expect(await page.locator('body').innerText()).not.toContain('sk-e2e-')
     await page.keyboard.press('Escape')
     await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e')
@@ -80,4 +75,22 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
       await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
+
+  it('replaces a saved Chat Completions default through the model selector', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
+    await page.keyboard.press('Escape')
+    await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
+    await page.reload({ waitUntil: 'load' })
+    const input = page.locator('[data-composer-input]').first()
+    await expect.poll(() => input.isEnabled()).toBe(false)
+    await expect.poll(() => input.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型')
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click()
+    await expect.poll(() => input.isEnabled()).toBe(true)
+    await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-messages')
+    const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(settings).toContain('provider: deepseek-messages')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
 })

+ 1 - 8
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -21,14 +21,7 @@
   - paragraph: 填入各提供方的 API 密钥即可使用其模型。
   - list:
     - listitem:
-      - text: DeepSeek deepseek-official API 密钥
-      - textbox "API 密钥":
-        - /placeholder: 输入 API 密钥
-      - group: 自定义设置
-      - button "取消"
-      - button "保存"
-    - listitem:
-      - text: DeepSeek Messages deepseek-messages API 密钥
+      - text: DeepSeek deepseek-messages API 密钥
       - textbox "API 密钥":
         - /placeholder: 输入 API 密钥
       - group:

+ 2 - 7
apps/web/tests/expected/deepseek-messages-settings/picker.expected.md

@@ -1,13 +1,8 @@
 - menu "模型与推理等级":
   - group "DeepSeek":
     - text: DeepSeek
-    - menuitemradio "DeepSeek-V4-Flash" [checked]:
-      - text: DeepSeek-V4-Flash
+    - menuitemradio "Messages Flash" [checked]:
+      - text: Messages Flash
       - img
     - menuitemradio "DeepSeek-V4-Pro"
     - menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
-  - group "DeepSeek Messages":
-    - text: DeepSeek Messages
-    - menuitemradio "Messages Flash"
-    - menuitemradio "DeepSeek-V4-Pro"
-    - menuitemradio "DeepSeek-V4-Flash-Vision-Exp"

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

@@ -23,14 +23,14 @@
     - listitem:
       - text: DeepSeek
       - img "API 密钥已配置"
-      - button "编辑 DeepSeek (deepseek-official)": 编辑
-      - text: DeepSeek deepseek-official API 密钥
+      - button "编辑 DeepSeek (deepseek-messages)": 编辑
+      - text: DeepSeek deepseek-messages API 密钥
       - textbox "API 密钥":
         - /placeholder: 已配置——输入新值可替换
       - group:
         - text: 自定义设置 API 地址
         - textbox "API 地址":
-          - /placeholder: https://api.deepseek.com
+          - /placeholder: https://api.deepseek.com/anthropic
         - region "模型目录":
           - text: 模型目录 已自定义模型目录
           - button "恢复默认模型"

+ 1 - 1
apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md

@@ -23,7 +23,7 @@
     - listitem:
       - text: DeepSeek
       - img "API 密钥缺失"
-      - button "编辑 DeepSeek (deepseek-official)": 编辑
+      - button "编辑 DeepSeek (deepseek-messages)": 编辑
   - text: 提供方
   - combobox "提供方":
     - option "amazon-bedrock"

+ 2 - 2
apps/web/tests/onboarding-usable-provider.e2e.ts

@@ -77,7 +77,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
       async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
       { timeout: 10_000 },
     ).toBe(1)
-    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 })
     const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
 
@@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
     await page.getByRole('button', { name: '设置', exact: true }).click()
     await settings.waitFor({ timeout: 10_000 })
     await settings.getByRole('button', { name: '模型' }).click()
-    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 })
     expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
 
     expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)

+ 20 - 14
apps/web/tests/scaffold.ts

@@ -246,11 +246,14 @@ class RouteOnlyAdapter extends LlmAdapter {
   }
 }
 
-function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
-  if (contextWindow === undefined) return REPLAY_PROVIDERS
+function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
   return REPLAY_PROVIDERS.map(provider => ({
     ...provider,
-    models: provider.models.map(model => ({ ...model, contextWindow })),
+    id: messages ? 'deepseek-messages' : provider.id,
+    models: provider.models.map(model => ({
+      ...model,
+      ...contextWindow === undefined ? {} : { contextWindow },
+    })),
   }))
 }
 
@@ -358,7 +361,7 @@ export interface LaunchOptions {
    * keyless first-run configuration lane; the default disables the adapter.
    */
   deepSeekMissingCredential?: boolean
-  /** Keep the shipped Messages adapter mounted for configuration scenarios; disabled in ordinary keyless replays. */
+  /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
   deepSeekMessages?: boolean
   /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
   welcomeNoticePending?: boolean
@@ -441,6 +444,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
   }
   const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
+  const messages = options.deepSeekMessages === true || options.deepSeekMissingCredential === true
   const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
   let credentialEnvironmentRestored = false
   const restoreCredentialEnvironment = (): void => {
@@ -514,6 +518,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
   const patches: PatchOptions[] = [
     ...basePatches,
     ...surfacePatches,
+    // Historical Session fixtures retain their provider identity. Shipped
+    // configuration scenarios and new Messages recordings use the Web default.
+    ...messages ? [] : [{
+      id: 'agent-default-model',
+      config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    }],
     ...extraOverlayPatches,
     // The roster's shipped presets are the plugin's own, bundled inside
     // `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -631,12 +641,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
           baseURL: options.deepSeekSearch.baseURL,
         },
       }],
-    ...mode === 'record' || options.deepSeekMissingCredential === true
-      ? []
-      : [{ id: 'llm-deepseek', disabled: true }],
-    ...options.deepSeekMessages === true
-      ? []
-      : [{ id: 'llm-deepseek-messages', disabled: true }],
+    { id: 'llm-deepseek', disabled: messages || mode !== 'record' },
+    { id: 'llm-deepseek-messages', disabled: !messages || (mode !== 'record' && !maskDeepSeekCredential) },
   ]
 
   // Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -724,7 +730,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     port = boundPort
 
     // Fill the open llm seam on the settled root ctx. Ordinary keyless modes
-    // disable both direct adapters; the first-run lane keeps llm-deepseek but has no
+    // disable both direct adapters; the first-run lane keeps Messages but has no
     // replay fixture and never streams. The direct install, unlike the plugin
     // row, returns the ReplayHandle for the teardown consumption check.
     if (options.replayProvidersOnly) {
@@ -761,7 +767,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     if (mode !== 'record' && replayFixture !== undefined) {
       replayHandle = installLlmReplay(ctx, {
         file: replayFixture,
-        providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
+        providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
           ...provider,
           ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
         })),
@@ -776,8 +782,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       // a fixture would, with streaming that still fails loud: the scenario
       // issues no model calls, and one that slipped in must not pass quietly.
       ctx.effect(() => ctx.llm.registerAdapter(
-        replayProviders(options.replayContextWindow).map(provider => provider.id),
-        new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
+        replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
+        new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
       ), 'web e2e scaffold: route-only adapter')
     }
     baseUrl = `http://${browserHost}:${String(port)}`

+ 4 - 4
apps/web/tests/shipped-composition.e2e.ts

@@ -84,7 +84,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
   expect(index.headers.get('content-encoding')).toBe('gzip')
   expect(index.headers.get('vary')).toContain('Accept-Encoding')
   await index.body?.cancel()
-  expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
+  expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(`
     {
       "initialDelayMs": 500,
       "jitterRatio": 0.1,
@@ -100,10 +100,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
       ],
     }
   `)
-  await ctx.settings.update('llm-deepseek', {
+  await ctx.settings.update('llm-deepseek-messages', {
     retryPolicy: { mode: 'always', maxRetries: 5 },
   })
-  expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
+  expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(`
     {
       "initialDelayMs": 500,
       "jitterRatio": 0.1,
@@ -178,7 +178,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
   const commandHandle = await scaffold.ctx.agents.create({
     sessionId: SessionId('shipped-command-catalog'),
     meta: { cwd: scaffold.workspaceCwd },
-    agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+    agentOptions: { provider: 'deepseek-messages', model: 'deepseek-v4-flash' },
   })
   try {
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({

+ 37 - 45
apps/web/tests/smoke-real.e2e.ts

@@ -32,6 +32,21 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir
 const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
 const authenticatedCookies = new Map<string, Promise<{ origin: string; cookie: string }>>()
 
+/** Messages SSE text, optionally truncated before content and message completion. */
+function messagesResponse(text: string, complete = true): string {
+  const events: Array<{ type: string; [key: string]: unknown }> = [
+    { type: 'message_start', message: { id: 'msg_smoke', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
+    { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
+    { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
+  ]
+  if (complete) events.push(
+    { type: 'content_block_stop', index: 0 },
+    { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
+    { type: 'message_stop' },
+  )
+  return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
+}
+
 /** Exchange a printed process token once for Node-side HTTP/WebSocket probes. */
 function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> {
   const existing = authenticatedCookies.get(launchUrl)
@@ -392,8 +407,9 @@ describe('dsh web keyless CLI smoke', () => {
     writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
 
     interface NativeProviderRequest {
-      messages?: { role?: string; content?: string }[]
-      tools?: { function?: { name?: string } }[]
+      system?: string
+      messages?: { role?: string; content?: { type?: string; text?: string }[] }[]
+      tools?: { name?: string }[]
     }
     let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
     const requests: NativeProviderRequest[] = []
@@ -409,13 +425,7 @@ describe('dsh web keyless CLI smoke', () => {
         if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
         if (requests.length === 1) resolveProviderRequests(requests)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
-        response.end([
-          'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
-          'data: {"choices":[{"delta":{"content":"done"}}]}',
-          'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
-          'data: [DONE]',
-          '',
-        ].join('\n\n'))
+        response.end(messagesResponse('done'))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -430,7 +440,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-workspace',
-          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
           DSH_HOME: join(workspace, '.dsh'),
           DSH_AGENTS_HOME: join(workspace, '.agents'),
           TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
@@ -457,15 +467,14 @@ describe('dsh web keyless CLI smoke', () => {
       if (captured === undefined) {
         throw new Error('provider did not receive the workspace projection request')
       }
-      const workspaceMessage = captured.messages?.find(message =>
-        message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
-      const systemMessage = captured.messages?.find(message => message.role === 'system')
+      const workspaceMessage = captured.messages?.flatMap(message => message.role === 'user' ? message.content ?? [] : [])
+        .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe'))
       const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
         .replace('{{webUrl}}', new URL(baseUrl).origin)
-      expect(systemMessage?.content).toContain(expectedWebSection)
+      expect(captured.system).toContain(expectedWebSection)
       expect(workspaceMessage).toMatchInlineSnapshot(`
         {
-          "content": "<system-reminder>
+          "text": "<system-reminder>
         The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
 
         Instructions from: AGENTS.md
@@ -473,10 +482,10 @@ describe('dsh web keyless CLI smoke', () => {
         web-workspace-context-probe
 
         </system-reminder>",
-          "role": "user",
+          "type": "text",
         }
       `)
-      expect(captured.tools?.map(tool => tool.function?.name)
+      expect(captured.tools?.map(tool => tool.name)
         .filter(name => name === 'web_search' || name === 'web_fetch'))
         .toMatchInlineSnapshot(`
           [
@@ -511,26 +520,16 @@ describe('dsh web keyless CLI smoke', () => {
         const mainRequest = !titleRequest && body.includes(promptMarker)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
         if (!mainRequest) {
-          response.end([
-            'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
-            'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
-            'data: [DONE]',
-            '',
-          ].join('\n\n'))
+          response.end(messagesResponse('Web retry title'))
           return
         }
         mainAttempts++
         if (mainAttempts === 1) {
-          response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
+          response.write(messagesResponse('WEB_RETRY_DISCARDED', false))
           setTimeout(() => { response.destroy() }, 20)
           return
         }
-        response.end([
-          `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
-          'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
-          'data: [DONE]',
-          '',
-        ].join('\n\n'))
+        response.end(messagesResponse(recoveredMarker))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -545,7 +544,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-retry',
-          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
           DSH_HOME: join(workspace, '.dsh'),
           TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
         },
@@ -595,8 +594,8 @@ describe('dsh web keyless CLI smoke', () => {
     const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-'))
 
     interface PtcModeProviderRequest {
-      messages?: { role?: string; content?: string }[]
-      tools?: { function?: { name?: string } }[]
+      system?: string
+      tools?: { name?: string }[]
     }
     let resolveProviderRequest!: (request: PtcModeProviderRequest) => void
     const providerRequest = new Promise<PtcModeProviderRequest>((resolve) => {
@@ -609,13 +608,7 @@ describe('dsh web keyless CLI smoke', () => {
       request.on('end', () => {
         resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
-        response.end([
-          'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
-          'data: {"choices":[{"delta":{"content":"done"}}]}',
-          'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
-          'data: [DONE]',
-          '',
-        ].join('\n\n'))
+        response.end(messagesResponse('done'))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -630,7 +623,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-ptc',
-          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
           DSH_TOOLS_MODE: 'ptc',
           DSH_HOME: join(workspace, '.dsh'),
           DSH_AGENTS_HOME: join(workspace, '.agents'),
@@ -654,10 +647,9 @@ describe('dsh web keyless CLI smoke', () => {
           setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
         }),
       ])
-      expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code'])
-      const system = captured.messages?.find(message => message.role === 'system')
-      expect(system?.content).toContain('## Writing code for run_code')
-      expect(system?.content).toContain('declare const tools')
+      expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code'])
+      expect(captured.system).toContain('## Writing code for run_code')
+      expect(captured.system).toContain('declare const tools')
     } finally {
       const closed = child.exitCode === null
         ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })

+ 1 - 0
apps/web/tsconfig.json

@@ -43,6 +43,7 @@
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
     "tests/deepseek-messages-settings.e2e.ts",
+    "tests/deepseek-messages-chat.e2e.ts",
     "tests/default-model.e2e.ts",
     "tests/github-ready-review.e2e.ts",
     "tests/streaming-fence-highlight.e2e.ts",

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: fb71f5f8ac89d513619d0473349b0942d8d4cba0
-README.zh.md: 4ada766c902b402983ac05df1d2c06b41c361506
+README.md: da51f7c4506ad87dac9d5e720470a2a6aa3fb3d9
+README.zh.md: e55063fe10e274658a6c46e879bc9f2ac92b35da

+ 3 - 1
packages/bundle/web-app/README.md

@@ -36,7 +36,9 @@ dsh --profile web --no-open --port 8080
 
 After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the clean root page. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
 
-**Settings → Models** offers separate DeepSeek and [DeepSeek Messages](../../llm/llm-deepseek-messages/README.md) cards. Both protocols can remain configured together; choose the provider in the model selector.
+**Settings → Models** shows **DeepSeek**, backed by the [Messages adapter](../../llm/llm-deepseek-messages/README.md). The Web default is `deepseek-messages` / `deepseek-v4-flash`, using `DEEPSEEK_API_KEY`; the Web patch disables the Chat Completions adapter while other profiles retain their own composition.
+
+Saved model selections override the composition default. To switch a saved `deepseek-official` default or an existing conversation, select a model under **DeepSeek** in the composer; this saves the new default for later sessions without rewriting earlier request headers. Messages endpoint and model settings belong to `llm-deepseek-messages`; Chat Completions endpoint overrides are not copied.
 
 ### Configuration
 

+ 3 - 1
packages/bundle/web-app/README.zh.md

@@ -36,7 +36,9 @@ dsh --profile web --no-open --port 8080
 
 启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到干净的根页面。页面加载且你可以与 agent(智能体)对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
 
-**设置 → 模型**提供独立的 DeepSeek 与 [DeepSeek Messages](../../llm/llm-deepseek-messages/README.zh.md) 卡片。两种协议可以同时配置,在模型选择器中切换提供方。
+**设置 → 模型**显示由 [Messages 适配器](../../llm/llm-deepseek-messages/README.zh.md) 提供的 **DeepSeek**。Web 默认选择 `deepseek-messages` / `deepseek-v4-flash`,使用 `DEEPSEEK_API_KEY`;Web 补丁禁用 Chat Completions 适配器,其他 profile 保持各自组合。
+
+已保存的模型选择优先于组合默认值。要切换已保存的 `deepseek-official` 默认值或已有会话,在输入框的 **DeepSeek** 分组下选择模型即可;此操作会保存后续会话的默认模型,不改写此前的请求头。Messages 端点和模型设置属于 `llm-deepseek-messages`,不会复制 Chat Completions 的端点覆盖值。
 
 ### 配置
 

+ 9 - 2
packages/bundle/web-app/cordis.patch.yml

@@ -13,6 +13,14 @@
 
 # ── surface-specific values the base deliberately omits ─────────────────────
 
+- id: llm-deepseek
+  disabled: true
+
+- id: agent-default-model
+  config:
+    provider: deepseek-messages
+    model: deepseek-v4-flash
+
 - id: system-prompt
   config:
     personaSuffix: Your working directory is {{cwd}}.
@@ -42,11 +50,10 @@
 # `dsh.client` rows are the browser roster the modules node half scans into
 # window.__DSH_BOOT__; the modules row is simultaneously a host row.
 - insert:
-    # Keep Messages credentials independent from the Chat Completions route.
     - id: llm-deepseek-messages
       name: '@deepseek-ai/dsh-llm-deepseek-messages'
       config:
-        apiKeyEnv: DEEPSEEK_MESSAGES_API_KEY
+        apiKeyEnv: DEEPSEEK_API_KEY
 
     # Host-owned opt-in sampled when a new Web session receives its preset
     # delegation tools. The Plugins page edits this settings namespace.

+ 2 - 2
packages/client/ui-settings-models/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-settings-models/README.md
-README.md: d012d9846f6a18078b3557b6c026293d999836f4
-README.zh.md: 047679cfaa23ebe119fcea88731a9063e8f7009f
+README.md: bfc58338423d943ecbe42dec95ac25d1b1408cb3
+README.zh.md: d356a97a9674ae87175ba784a3786633239e6c97

+ 1 - 1
packages/client/ui-settings-models/README.md

@@ -69,7 +69,7 @@ Each settings write carries the card's current `revision`, so a concurrent write
 
 ### Onboarding coordinator
 
-The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
+The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step targets `deepseek-messages` in `llm-deepseek-messages` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
 
 </details>
 

+ 1 - 1
packages/client/ui-settings-models/README.zh.md

@@ -69,7 +69,7 @@ kind: "package-reference"
 
 ### 引导协调器
 
-声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
+声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek-messages` 中的 `deepseek-messages`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
 
 </details>
 

+ 3 - 3
packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx

@@ -81,10 +81,10 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
   }
 
   const row = state.rows.find(candidate =>
-    candidate.entry.provider === 'deepseek-official'
-    && candidate.entry.settingsNs === 'llm-deepseek'
+    candidate.entry.provider === 'deepseek-messages'
+    && candidate.entry.settingsNs === 'llm-deepseek-messages'
     && candidate.entry.settingsPath.length === 0)
-  const namespace = state.namespaces.get('llm-deepseek')
+  const namespace = state.namespaces.get('llm-deepseek-messages')
   /* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
   if (row === undefined || namespace === undefined) return null
 

+ 1 - 1
packages/client/ui-settings-models/src/client/index.ts

@@ -147,7 +147,7 @@ export function apply(ctx: ClientContext): void {
   }, WelcomeNotice))
   ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
     name: 'settings.onboarding',
-    id: 'deepseek-official',
+    id: 'deepseek-messages',
     order: 0,
     inject: deepSeekOnboardingInjected,
   }, DeepSeekOnboardingDialog))

+ 2 - 2
packages/client/ui-settings-models/src/client/store.ts

@@ -304,8 +304,8 @@ export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadi
   }
   if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
   const row = state.rows.find(candidate =>
-    candidate.entry.provider === 'deepseek-official'
-    && candidate.entry.settingsNs === 'llm-deepseek'
+    candidate.entry.provider === 'deepseek-messages'
+    && candidate.entry.settingsNs === 'llm-deepseek-messages'
     && candidate.entry.settingsPath.length === 0)
   if (row === undefined) return { kind: 'adapter-absent' }
   if (!row.entry.active) {

+ 3 - 3
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -97,9 +97,9 @@ describe('ui-settings-models apply', () => {
       component: WelcomeNotice,
       options: { id: 'welcome-notice', order: -100 },
     })
-    const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
+    const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-messages')!
     expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
-    expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
+    expect(deepSeek.options).toMatchObject({ id: 'deepseek-messages', order: 0 })
     const deepSeekInjected = (
       deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
     )()
@@ -245,7 +245,7 @@ describe('pushed invalidations', () => {
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const entry = b.slots.entries('settings.onboarding')
-      .find(candidate => candidate.options.id === 'deepseek-official')!
+      .find(candidate => candidate.options.id === 'deepseek-messages')!
     const injected = (
       entry.inject as unknown as
       () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected

+ 5 - 5
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -47,7 +47,7 @@ const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPen
 function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
   const value = apiKeyEnv === null ? {} : { apiKeyEnv }
   return {
-    ns: 'llm-deepseek',
+    ns: 'llm-deepseek-messages',
     schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
     value,
     base: value,
@@ -92,16 +92,16 @@ function harness(options: {
         return Promise.resolve(remoteOk(
           options.provider === false || options.providerActive === false
             ? []
-            : [{ id: 'deepseek-official', name: 'DeepSeek' }],
+            : [{ id: 'deepseek-messages', name: 'DeepSeek' }],
         ))
       },
       listConfigurableProviders: () => Promise.resolve(remoteOk(
         options.provider === false
           ? []
           : [{
-            provider: 'deepseek-official',
+            provider: 'deepseek-messages',
             displayName: 'DeepSeek',
-            settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
+            settingsNs: options.providerSettingsNs ?? 'llm-deepseek-messages',
             settingsPath: [],
           }],
       )),
@@ -138,7 +138,7 @@ function harness(options: {
   const complete = vi.fn()
   const unusedHook = (() => { throw new Error('unused standard hook') }) as never
   const props: DeepSeekOnboardingDialogProps = {
-    stepId: 'deepseek-official',
+    stepId: 'deepseek-messages',
     complete,
     openSection,
     useSessions: unusedHook,

+ 2 - 2
packages/client/ui-settings-models/tests/readiness.client.spec.ts

@@ -9,9 +9,9 @@ const missingCredential: CredentialInfo = { configured: false, writable: true }
 function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
   return {
     entry: {
-      provider: 'deepseek-official',
+      provider: 'deepseek-messages',
       displayName: 'DeepSeek',
-      settingsNs: 'llm-deepseek',
+      settingsNs: 'llm-deepseek-messages',
       settingsPath: [],
       active: true,
     },

+ 1 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1673,7 +1673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
     occupants: [
       'client-ui-settings-models WelcomeNotice id \'welcome-notice\'',
-      'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
+      'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-messages\'',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n      { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',

+ 2 - 2
packages/llm/llm-deepseek-messages/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/llm/llm-deepseek-messages/README.md
-README.md: bad9d561a8d08e48a1ce75e9a670cbf640da4a6f
-README.zh.md: 9da666d981209a28cf9f3e01b2ad3a5cfa746ce4
+README.md: 4ff26629899b4f03c64e782cb82ddaf7928b1866
+README.zh.md: 07825fa8b2e9b8534e8f7a2a7d2bd89c8423be09

+ 1 - 1
packages/llm/llm-deepseek-messages/README.md

@@ -27,7 +27,7 @@ Use DeepSeek through an Anthropic Messages endpoint while retaining Harness tool
 
 Mount this plugin beside `dsh-llm` in a Cordis composition and select `provider: deepseek-messages`. Model ids pass through unchanged; the catalog is advisory.
 
-The [Web profile](../../bundle/web-app/README.md) mounts this adapter beside Chat Completions. In **Settings → Models**, configure **DeepSeek Messages**, then select its model in the composer. Web uses the independent `DEEPSEEK_MESSAGES_API_KEY` credential reference; entering a key on this card does not replace `DEEPSEEK_API_KEY`. Its endpoint and model edits apply live under `llm-deepseek-messages`. The default selected provider remains `deepseek-official`.
+The [Web profile](../../bundle/web-app/README.md) uses this adapter as its default DeepSeek provider and disables Chat Completions. The **DeepSeek** card and first-run prompt use `DEEPSEEK_API_KEY`; endpoint and model edits apply live under `llm-deepseek-messages`. The provider id remains `deepseek-messages` independently of its display name.
 
 ### Minimal configuration
 

+ 1 - 1
packages/llm/llm-deepseek-messages/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 在 Cordis 组合中将本插件与 `dsh-llm` 一起挂载,并选择 `provider: deepseek-messages`。模型 ID 原样发送,目录仅供发现使用。
 
-[Web profile](../../bundle/web-app/README.zh.md) 同时挂载本适配器与 Chat Completions。在**设置 → 模型**中配置 **DeepSeek Messages**,然后在输入框的模型选择器中选择它的模型。Web 使用独立的 `DEEPSEEK_MESSAGES_API_KEY` 凭据引用;在此卡片输入密钥不会替换 `DEEPSEEK_API_KEY`。端点和模型修改通过 `llm-deepseek-messages` 即时生效。默认选择的提供方仍为 `deepseek-official`
+[Web profile](../../bundle/web-app/README.zh.md) 默认使用本适配器作为 DeepSeek 提供方,并禁用 Chat Completions。**DeepSeek** 卡片和首次启动引导使用 `DEEPSEEK_API_KEY`;端点和模型修改通过 `llm-deepseek-messages` 即时生效。提供方 ID 保持为 `deepseek-messages`,与显示名称相互独立
 
 ### 最小配置
 

+ 1 - 1
packages/llm/llm-deepseek-messages/src/adapter.ts

@@ -30,7 +30,7 @@ export interface AdapterDependencies {
 export class DeepSeekMessagesAdapter extends LlmAdapter {
   constructor(private readonly dependencies: AdapterDependencies) { super() }
 
-  override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek Messages' } }
+  override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } }
   override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy }
   override listModels(provider: string) {
     const connection = this.dependencies.connection()

+ 1 - 1
packages/llm/llm-deepseek-messages/src/index.ts

@@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config): void {
       return attachments === undefined ? undefined : resolveImageAttachmentAccess(attachments, path => ctx.get('fs')?.processPathFromHostPath(path), ref)
     },
   })
-  ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek Messages', settingsNs: name, settingsPath: [] }])
+  ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: name, settingsPath: [] }])
   const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
   let retryPolicy = current.retryPolicy
   ctx.inject(['settings'], (child) => {

+ 1 - 1
packages/llm/llm-deepseek-messages/tests/adapter.spec.ts

@@ -49,7 +49,7 @@ describe('direct Messages HTTP', () => {
       'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user',
       'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1',
     }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } })
-    expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek Messages' })
+    expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek' })
     expect(await llm.listModels('deepseek-messages')).toHaveLength(3)
     expect(await llm.resolveModel('deepseek-messages', MODEL)).toMatchObject({ id: MODEL })
     expect(llm.imageRequestPricing('deepseek-messages', MODEL)).toBeDefined()

+ 16 - 0
snapshots/web/deepseek-messages-chat/session.v2.jsonl

@@ -0,0 +1,16 @@
+{"type":"session","version":2,"id":"{{session:1}}","createdAt":1788785092857,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"}
+{"type":"permission/preset","data":{"preset":"workspace-write"}}
+{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
+{"type":"approval/policy","data":{"policy":"ask"}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"user/message","data":{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"只回复 MESSAGES_WEB_READY,不调用","messageSeqs":[7],"source":{"kind":"fallback"}}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-messages","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","data":{"provider":"deepseek-messages","model":"deepseek-v4-flash","contextWindow":1000000}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"MESSAGES_WEB_READY"}],"source":{"kind":"model","provider":"deepseek-messages","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}},"id":"{{message:3}}"},"usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174},"stream":[{"type":"chunk","time":1788785093863,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788785094864,"index":1,"dt":[21,0,0,0,0,22,0],"texts":["M","ESS","AGES","_W","EB","_","READ","Y"]},{"type":"chunk","time":1788785094907,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MESSAGES_WEB_READY"}}},{"type":"chunk","time":1788785094907,"chunk":{"type":"usage","usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174}}},{"type":"chunk","time":1788785094908,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}}}]},"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 7 - 0
snapshots/web/deepseek-messages-chat/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: deepseek-messages-chat
+profile: web
+composition: web-default
+recording: live
+header:
+  class: web-default

+ 53 - 0
snapshots/web/deepseek-messages-chat/ui.expected.md

@@ -0,0 +1,53 @@
+- banner:
+  - navigation "会话层级":
+    - button "只回复 MESSAGES_WEB_READY,不调用" [disabled]
+  - img
+  - text: 标准模式
+  - button "Session 日志":
+    - text: Session 日志
+    - img
+  - tablist:
+    - tab "对话" [selected]
+    - tab "轨迹"
+- button "系统提示词":
+  - img
+  - img
+  - text: 系统提示词
+- text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}}
+- button "复制":
+  - img
+- button "已思考":
+  - text: 已思考
+  - img
+- button "思考":
+  - img
+  - img
+  - text: 思考
+- paragraph: MESSAGES_WEB_READY
+- button "复制":
+  - img
+- button "好的回答":
+  - img
+- button "有问题的回答":
+  - img
+- button "在新对话中分支":
+  - img
+- button "用量 8.2K tok":
+  - img
+  - text: 用量 8.2K tok
+- button "用时 {{duration}}":
+  - img
+  - text: 用时 {{duration}}
+- text: {{clock}}
+- textbox "发消息或做任务… / 调用指令 @ 文件或对话"
+- button "指令":
+  - img
+- button "添加附件":
+  - img
+- button "访问模式,当前:工作区内修改": 工作区内修改
+- button "选择模型,当前 DeepSeek-V4-Flash,推理等级 high":
+  - text: DeepSeek-V4-Flash high
+  - img
+- button "上下文已用 1%"
+- button "发送消息" [disabled]
+- text: 1 轮 · 1 步 LLM {{duration}} 首 token 平均 {{duration}} · {{throughput}} tok/s 缓存命中 16% 输入 8.2K tok · 输出 9 tok

+ 1 - 0
tsconfig.host.json

@@ -34,6 +34,7 @@
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
     "apps/web/tests/deepseek-messages-settings.e2e.ts",
+    "apps/web/tests/deepseek-messages-chat.e2e.ts",
     "apps/web/tests/onboarding-deepseek-config.e2e.ts",
     "apps/web/tests/onboarding-usable-provider.e2e.ts",
     "apps/web/tests/remote-welcome.e2e.ts",