Merge branch 'master' into feat/web-plugin-settings-tabs

This commit is contained in:
Ziya
2026-08-12 18:40:38 +08:00
committed by GitHub
124 changed files with 3272 additions and 222 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: e8bc99f7b2b15f0679ec268a8e778e815758c00f
README.zh.md: c75c487caa7e2ffd7ca5c4b16f3c6243f0a86f3e
README.md: 17d63fe73ea2b3bda74e9c4da91555b34c21f4ab
README.zh.md: ee99592fbba07b52b29db534e12c1d0ef23e5ef0

View File

@@ -73,9 +73,9 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
## Shared deployment behavior
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision.
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.

View File

@@ -73,9 +73,9 @@ dsh web --help
## 共享部署行为
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env``$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env``$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
会话事件默认作为 OTLP/HTTP 日志流式发送`DSH_TELEMETRY_OTLP_URL` 选择其他 collector任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀`DSH_TELEMETRY_OTLP_URL` 选择其他 collector任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的硬性退出开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md)负责。
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent智能体沙箱之外的受信任可执行代码。

View File

@@ -75,7 +75,7 @@ export const PROFILE_ROOT_FILENAME = 'cordis.yml'
* switch set.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the telemetry row.
* @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted.
* @returns the disable patch, or `undefined` when no hard-disable patch is required.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { resolveTelemetryPatch } from '../src/profile-boot.ts'
describe('resolveTelemetryPatch', () => {
it('keeps telemetry enabled when the switch is unset or empty', () => {
it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => {
expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
expect(resolveTelemetryPatch('', true)).toBeUndefined()
})

View File

@@ -0,0 +1,121 @@
// Keyless browser regression for durable per-message feedback. Cold-seeds a
// settled two-turn transcript (zero model calls), rates one assistant message,
// attaches a note, proves both survive a full page reload from the Host's
// message-feedback sidecar, then retracts the rating.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold,
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled assistant message to
// address, not a new recording (message-actions / sidebar-scrollbar pattern).
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'message-feedback-web-e2e'
const NOTE = 'Read both files before answering.'
describe('web e2e: durable per-message feedback', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Open the seeded transcript. The first treeitem is the collapsible group
* row; the session itself is the row beneath it. The group is already
* expanded on a fresh load, so clicking it unconditionally would collapse it
* and hide the session row.
*/
async function openSeededSession(): Promise<void> {
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 15_000 })
await sessionRow.click()
}
it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
await openSeededSession()
// The controls live in the assistant message's IconActions row, which the
// transcript reveals on hover/focus like copy and branch. Wait for the
// settled closing text first: the strip mounts with that turn's tail.
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
const like = page.getByRole('button', { name: 'Good response' }).first()
await like.waitFor({ timeout: 30_000 })
await like.scrollIntoViewIfNeeded()
await like.hover()
await like.click()
// A recorded rating relabels the button to what the next click would do,
// so the pressed control is addressed by the retract label from here on.
const rated = page.getByRole('button', { name: 'Remove rating' }).first()
await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true')
// A rated message offers the note editor; an unrated one does not.
await page.getByRole('button', { name: 'Add a note' }).first().click()
const editor = page.getByRole('textbox', { name: 'Feedback note' })
await editor.fill(NOTE)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0)
await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
// The durable assertion: a cold browser re-reads the sidecar over the wire.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession()
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
// The controller defers its list read to the first hover or focus, so a
// cold reload shows the unrated label until the strip is touched. Hovering
// the unrated control is what triggers the authoritative re-read.
const cold = page.getByRole('button', { name: 'Good response' }).first()
await cold.waitFor({ timeout: 30_000 })
await cold.scrollIntoViewIfNeeded()
await cold.hover()
const restored = page.getByRole('button', { name: 'Remove rating' }).first()
await restored.waitFor({ timeout: 30_000 })
await restored.scrollIntoViewIfNeeded()
await restored.hover()
await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
// Re-clicking the active rating retracts it, and the note goes with it.
await restored.click()
await expect.poll(
() => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'),
{ timeout: 10_000 },
).toBe('false')
await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
}, 90_000)
it.skipIf(MODE === 'record')('kept the console clean', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -33,6 +33,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -48,6 +48,10 @@
- paragraph: CORDIS_UI_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -20,6 +20,10 @@
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -78,6 +78,10 @@
- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
@@ -187,6 +191,10 @@
- text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- tooltip "Branch into a new conversation"

View File

@@ -20,6 +20,10 @@
- paragraph: LIGHTHOUSE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -22,6 +22,10 @@
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -35,6 +35,10 @@
- paragraph: CJK_STRONG_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -14,6 +14,10 @@
- paragraph: REMOTE_IMAGE_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -26,6 +26,10 @@
- paragraph: INLINE_CODE_LINK_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -30,6 +30,10 @@
- paragraph: MATH_RENDERING_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}

View File

@@ -15,6 +15,10 @@
- paragraph: I will read both files before answering.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
@@ -38,6 +42,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}}

View File

@@ -3,7 +3,7 @@
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
- paragraph:
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,
- text: 内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY如需持续上传可以设置 DSH_TELEMETRY_MODE=FULL但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
- button "继续"

View File

@@ -33,6 +33,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -28,6 +28,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -31,6 +31,10 @@
- paragraph: DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -20,6 +20,10 @@
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -30,6 +30,10 @@
- paragraph: "Got it: BANANA and ORANGE."
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -31,6 +31,10 @@
- paragraph: Great, let's move forward. BANANA!
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -25,6 +25,10 @@
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}}
@@ -37,6 +41,10 @@
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -20,6 +20,10 @@
- paragraph: SEARCH_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -27,6 +27,10 @@
- paragraph: WORKFLOW_DONE
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -55,6 +55,7 @@
"tests/cordis-tool-round.e2e.ts",
"tests/web-search-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",