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
@@ -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-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fe9ea3c4073e249a8c961634be9d329b35a3f4e
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a4b91a48175edcc624e6af44efa3e4c7676554
2026-07-10-single-file-executable-sdk-runtime-distribution.md: e45f566ee542fdab4ae55b45ff6808169e6e7cf6
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 735e9b6e7c7023eb429cc2f5f640606abf543720
@@ -42,7 +42,7 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly by `workflow_dispatch`, the `build-exe` label on a pull request, or the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
### Python SDK distribution: two carriers, exe for production, node for development
@@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c
## Testing
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
@@ -42,7 +42,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js``assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy``hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit``@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml)手动派发 `workflow_dispatch`PRPull Request)的 `build-exe` 标签[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)显式触发。linux-x64、linux-arm64`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSEServer-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml)[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch`PRPull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSEServer-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
@@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个
## 测试
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 IDSDK 结果与通知流,以及父会话和两个子会话 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
@@ -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-07-23-session-telemetry-otel-revival.md
2026-07-23-session-telemetry-otel-revival.md: 67ab0d28a168422a6b787a6088648746cd6079b6
2026-07-23-session-telemetry-otel-revival.zh.md: d59dcd07e9879e1c7cafc7a104d4f2515596cab8
2026-07-23-session-telemetry-otel-revival.md: c981a067efcc7107a7b7a9b9344a59f1a3bfe3d9
2026-07-23-session-telemetry-otel-revival.zh.md: c79d29687feeb1645b04031cc4c300a6465b1c38
@@ -14,7 +14,7 @@ Every deployment that wants harness sessions in an observability stack must hand
- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: live adoption with cursor read-back and the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), buffer-free on-demand replay from the canonical log, the fixed first-chunk-per-(turn, step) projection, the live `agent/error` relay, and live dispose-time `shutdown` records.
- **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary, while [buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the default and constructs no transport; the [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) defines the explicit `FULL` and `FEEDBACK_ONLY` delivery modes, which require `exporter.url`, without moving the redaction or backend boundary. [Buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly.
@@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry
## Consequences
A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` replays a canonical-log prefix when feedback is recorded, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited.
A deployment adds one `cordis.yml` entry with an OTLP endpoint and explicitly selects `FULL` to stream sessions into an OTel-compatible stack or `FEEDBACK_ONLY` to replay a canonical-log prefix when feedback is recorded. `DISABLED` is the [default](2026-08-10-telemetry-default-off.md) and constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited.
@@ -14,7 +14,7 @@ Status: implemented
- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend``emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的实时纳管与逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、从权威日志进行的无缓冲按需回放、固定的每个(轮次、步骤)组合首分片投影、实时 `agent/error` 转发,以及实时 dispose(资源释放)时的 `shutdown` 记录。
- **`telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY``DISABLED` 投递模式,但未移动脱敏或后端边界,而[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是默认值,且不构造任何传输;[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)定义了需显式启用的 `FULL``FEEDBACK_ONLY` 投递模式,这两种模式要求 `exporter.url`,且不移动脱敏或后端边界[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),两份 README 对此如实陈述。
@@ -34,4 +34,4 @@ Status: implemented
## 后果
部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项即可把会话流接入任何 OTel 兼容体系`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在记录反馈时回放权威日志前缀`DISABLED` 不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。
部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项,并显式选择 `FULL`即可把会话流接入任何 OTel 兼容体系;选择 `FEEDBACK_ONLY` 则会在记录反馈时回放权威日志前缀`DISABLED` 是[默认值](2026-08-10-telemetry-default-off.md),且不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。
@@ -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-07-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: ff4bfc860492d7eca345d55449e4e4726b988854
2026-07-30-versioned-gui-welcome-onboarding.zh.md: dd293fe9e1e77414c4b352aa50e6b0456f866cce
2026-07-30-versioned-gui-welcome-onboarding.md: f8cb3b9f72335e34e5b6f5aef6966c8822717d5c
2026-07-30-versioned-gui-welcome-onboarding.zh.md: 5c9d87d4f834065bec8c67b55c3d3dba4eaa1d52
@@ -12,7 +12,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`.
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice states that session telemetry is [disabled by default](2026-08-10-telemetry-default-off.md) and names the `FEEDBACK_ONLY` and `FULL` opt-in modes.
**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again.
@@ -12,7 +12,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()``openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100``ui-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知说明会话遥测[默认禁用](2026-08-10-telemetry-default-off.md),并列出 `FEEDBACK_ONLY``FULL` 两种显式启用模式。
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authorityhostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。更新该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。
@@ -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-07-31-web-telemetry-default-mount.md
2026-07-31-web-telemetry-default-mount.md: 3e20fa44666139778e85a8bd5b948ab2df14529b
2026-07-31-web-telemetry-default-mount.zh.md: 97b153255a1e64aba0a686faf1817b69467fb663
2026-07-31-web-telemetry-default-mount.md: 93bb788bd48ab8c82bd013c8f844e01913f537e4
2026-07-31-web-telemetry-default-mount.zh.md: d75d3e36f5e4b6c9fab146b28e301cfc3b11f8b6
@@ -10,23 +10,24 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
## Decision
The shared `dsh` base (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so Web and headless report; the raw-config command also mounts it before applying its required deployment overlay. This is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving the backend's three-second shutdown deadline time to drain before the five-second launcher bound.
The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `telemetry-otel` row with a baked-in production endpoint, so every profile has one consistent telemetry capability. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps that row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound.
| Ruling | Value | Rationale |
|---|---|---|
| Mount surface | base.cordis.yml (raw config + Web + headless) | One deployment stance for every tree that loads the shared base; the raw overlay decides whether that deployment creates sessions |
| Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base |
| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies |
| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
| Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode |
| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | Ordinary unreachable-collector failure releases in ~1s: timeoutMillis is the per-attempt socket timeout and retry deadline, while one queue-sized batch avoids sequential drain multiplication. The DSH-owned 3s outer bound covers the SDK's preceding unbounded `forceFlush()` wait when the transport Promise never obtains a socket. |
| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode |
The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the deployment-level behavior: an in-test OTLP collector plus a mock LLM server, a real `dsh web` boot, asserting ledger coverage, seq monotonicity, the first-of-step chunk projection, and the ops `shutdown` marker arriving through the SIGINT drain.
The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery.
## Alternatives considered
**No default mount; deployments add the row themselves (continuing the SDK stance).** Rejected for this stage: this repo's web/headless composition IS the internal deployment, and default-on reporting is that deployment's product requirement; the SDK stance survives in the Service Definition packages (unmounted = nothing leaves).
**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives all profiles one patch target without authorizing any upload.
**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
@@ -34,6 +35,6 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
## Consequences
- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally.
- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, the remaining identity Resource attributes (hostname / surface; the anonymous user id shipped via the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md)), and the usage-metrics track are the explicit follow-ups of this decision.
- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name.
- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector.
- **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md).
- Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode.
@@ -10,23 +10,24 @@ Status: implemented
## 决策
`dsh` 共享 base`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就上报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空。
共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `telemetry-otel` 配置行,使每个 profile 都具有一致的遥测能力。[默认关闭决策](2026-08-10-telemetry-default-off.md)让该配置行保持 `DISABLED` 模式,除非部署方显式选择 `FULL``FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。
| 决策项 | 取值 | 理由 |
|---|---|---|
| 挂载面 | base.cordis.yml(原始配置 + Web + headless | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
| 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 |
| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL``FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 |
| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collectorenv 覆盖供本地/联调 |
| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
| 上报节奏 | `processor.scheduledDelayMillis: 10000`10s/批) | 流式回流,非退出才报;崩溃至多丢失最后一个尚未导出间隔内的数据 |
| 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 |
| 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 |
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048`(与 maxQueueSize 相等) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
| CI 隔离 | 全部 8 个 GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/快照/已构建版本的冒烟测试)不得向生产 endpoint 泄测试会话 |
| CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 |
集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM(大语言模型)服务器,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、步骤首个分片投影、以及 SIGINT drain 后 ops `shutdown` 标记到达
基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式
## 考虑过的替代方案
**默认不挂载,部署方自行加行(SDK 立场的延续)。** 当前阶段不采用:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 Service Definition 包保持(不挂 = 零外发)
**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为所有 profile 提供同一个 patch 目标,同时不授权任何上传
**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
@@ -34,6 +35,6 @@ Status: implemented
## 后果
- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` `DSH_TELEMETRY_OTLP_URL`本地
- **当前尚未挂载任何脱敏规则**:导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须 `telemetry/record` 规则——脱敏规则、其余身份 Resource 属性hostname/surface匿名 user id 由[匿名用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)落地)、使用数据 metrics 轨是本决策明确的后续工作
- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture(测试前置数据)会话会流向 env 里碰巧存在的 collector。
- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL`向其他 collector
- **没有挂载任何脱敏规则**显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `telemetry/record` 规则脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.md)交付
- 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式
@@ -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-08-05-feedback-gated-session-telemetry.md
2026-08-05-feedback-gated-session-telemetry.md: 7d923a7e4cf61e8d1119187564b87e4cbb2065b7
2026-08-05-feedback-gated-session-telemetry.zh.md: e2fa6f9561de6e7295cbf860527c7d7915fec0bf
2026-08-05-feedback-gated-session-telemetry.md: 524eb9defa9981c6d535ff68012d206b9c0cff94
2026-08-05-feedback-gated-session-telemetry.zh.md: 557e3681f627931560d19172ddd8fa69a2984af1
@@ -6,15 +6,15 @@ English | [中文](2026-08-05-feedback-gated-session-telemetry.zh.md)
## Problem
Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the existing full-export default and the telemetry seam's redaction-before-backend boundary.
Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the telemetry seam's redaction-before-backend boundary.
## Decision
`@deepseek-ai/dsh-session-telemetry-otel` exposes the string-valued `TelemetryMode` enum to TypeScript callers and accepts the same three uppercase `mode` values in serialized configuration:
- `FULL` is the default and preserves immediate delivery to the configured OTel pipeline.
- `FULL` explicitly selects immediate delivery to the configured OTel pipeline.
- `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event.
- `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local.
- `DISABLED` is the [default](2026-08-10-telemetry-default-off.md), constructs no exporter, processor, or logger provider, and prints that nothing is shared and the feedback remains local when it observes `feedback/record`.
The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records.
@@ -32,4 +32,4 @@ Mode resolution is a closed, fail-before-setup check: an unknown direct-construc
## Consequences
`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log.
`FULL` retains the original source and wire behavior as an explicit opt-in. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log.
@@ -6,15 +6,15 @@ Status: implemented
## 问题
会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留现有的全量导出默认值,以及遥测 seam 在记录抵达后端之前脱敏的边界。
会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留遥测 seam 在记录抵达后端之前脱敏的边界。
## 决策
`@deepseek-ai/dsh-session-telemetry-otel` 向 TypeScript 调用方公开以字符串为值的 `TelemetryMode` 枚举,并在序列化配置中接受相同的三个大写 `mode` 值:
- `FULL` 是默认值,保留向已配置 OTel 流水线即时投递。
- `FULL` 显式选择向已配置 OTel 流水线即时投递。
- `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。
- `DISABLED` 不构造导出器、处理器或日志提供方`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。
- `DISABLED` 是[默认值](2026-08-10-telemetry-default-off.md)不构造导出器、处理器或日志提供方,并在观察到 `feedback/record` 输出警告,说明什么都不会共享,且反馈仍留在本地。
通用遥测协调器拥有 `live``on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。
@@ -32,4 +32,4 @@ Status: implemented
## 后果
`FULL` 与原有默认值保持源码协议兼容`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。
`FULL` 作为显式启用模式保留原有的源码协议行为`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-08-10-telemetry-default-off.md
2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f
2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a
@@ -0,0 +1,31 @@
# Agent Note: Telemetry requires explicit opt-in
Status: implemented
English | [中文](2026-08-10-telemetry-default-off.zh.md)
## Problem
DeepSeek Harness has two outbound telemetry feeds. During internal testing, the shared base mounted telemetry with a baked-in production endpoint, and both feeds reported by default to help diagnose reported problems: the session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed did so unconditionally. A fresh installation therefore permitted outbound reporting without a positive deployment choice.
## Decision
Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings.
The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed.
The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy.
## Alternatives considered
**Keep opt-out defaults and improve disclosure.** Rejected because disclosure does not make a missing configuration a positive authorization to send data, especially when session telemetry can contain complete local content.
**Default session telemetry to `FEEDBACK_ONLY`.** Rejected because recording feedback would still trigger an upload without a deployment explicitly enabling outbound reporting. The default must keep both the session and its feedback local.
**Add project-level consent markers.** Rejected because `DSH_TELEMETRY_MODE` already expresses consent for both feeds; another configuration entry would create conflicting settings and require launcher-specific parsing.
**Remove both telemetry implementations.** Rejected because internal deployments still need explicit `FULL` and feedback-gated reporting, and the launcher feed remains useful under `FULL`.
## Consequences
Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior.
@@ -0,0 +1,31 @@
# Agent Note: 遥测必须显式启用
Status: implemented
[English](2026-08-10-telemetry-default-off.md) | 中文
## 问题
DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流则无条件外发。因此,全新安装无需部署方明确选择便允许向外上报。
## 决策
两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED``@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL``FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。
dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY``DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。
带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY``DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。
## 考虑过的替代方案
**保留默认退出机制并改进披露。** 不采用:披露不能让缺少配置构成发送数据的明确授权,尤其是会话遥测可能包含完整的本地内容。
**将会话遥测默认设为 `FEEDBACK_ONLY`。** 不采用:即使部署方没有显式启用向外上报,记录反馈仍会触发上传。默认值必须让会话及其反馈都留在本地。
**添加项目级授权标记。** 不采用:`DSH_TELEMETRY_MODE` 已能表达两路数据流的授权;另一个配置项会产生冲突设置,并需要启动器专用的解析逻辑。
**删除两种遥测实现。** 不采用:内部部署仍需要显式启用 `FULL` 与反馈触发的上报;在 `FULL` 下,启动器数据流也仍有用。
## 后果
全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-08-11-message-feedback-web-surface.md
2026-08-11-message-feedback-web-surface.md: 316e5ce7fbab051f3bc5bd586be11f9516505058
2026-08-11-message-feedback-web-surface.zh.md: eb6740f8264b41cb25a1cd60e8db7a295ce8e031
@@ -0,0 +1,57 @@
# Agent Note: Web surface for message feedback
Status: implemented
English | [中文](2026-08-11-message-feedback-web-surface.zh.md)
## Problem
[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) landed the durable message-feedback sidecar and its three Host Remote methods, but it was explicitly backend-only: no client package consumed `messageFeedback.list`, `put`, or `delete`, so the Web GUI had no way to record a rating. Its Agent Note deferred "client Remote aggregate mounting and UI" to a separate owner. Issue #1326 asks for the Web surface and was closed by that backend merge without the user-visible half existing.
An earlier full-stack attempt, [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010), carried a UI layer but was built against its own backend with a different shape: one Session-wide `revision` for compare-and-swap and RPC named `feedback.upsert`. #2217 shipped per-item `ifVersion` and `messageFeedback.put` instead, so #1010's controller logic no longer matched the contract, and its branch had also drifted structurally (it edited `packages/cordis/`, renamed to `packages/self-modification/`, and added a top-level `packages/session-feedback/` that conflicts with the consolidated `packages/feedback/`). It was closed as superseded rather than rebased.
The blocking gap for any UI was that the browser could not name a feedback target. The Host accepts only an append-origin `assistant/message` addressed by `MessageId`, but `AssistantMessageNode` — the client's finalized-assistant node — carried `seq`, `turn`, and `step` and no message identity. Only `SteeringMessageNode` had a `messageId`.
## Decision
Three seams, each owned where its authority already lives.
**Message identity in the client node.** `AssistantMessageNode` gains an optional `messageId`, copied from `event.data.message.id` where the node is materialized from a finalized `assistant/message`. It stays absent on interruption-frozen partials, which were never finalized and address no durable message, and on the synthetic sentinel the trajectory layout builds for an unfinalized partial. The field is optional precisely so those two cases remain unrepresentable as feedback targets rather than being papered over with a placeholder. `ui-conversation` and `ui-trajectory` each materialize their own copy of this node, so both finalized branches were updated; the interrupted branches were deliberately left alone. This mirrors the Host's own target rule, which filters on `isAppendSurfaceEvent`, so client and Host agree on what is addressable without sharing code.
**A declared slot rather than a direct dependency.** `ui-conversation` declares `conversation.chat.assistant-actions` (list kind, session scope, owner `{messageId}`) and authorizes it as a second child of the `turn-tail` node renderer, next to the existing `conversation.chat.turnTail` chain. `TurnTailNodeView` renders it and threads the result into `MessageIconActions` through a new `extraActions` prop, placed between copy and branch. The render site skips the slot entirely when `messageId` is absent, so an interrupted turn shows no controls. The feedback package therefore contributes an entry and never imports the conversation implementation; the strip renders nothing at zero cost when the plugin is composed out of `cordis.yml`.
`extraActions` is a `ReactNode` prop rather than a second render-slot hole because `MessageIconActions` is shared chrome for user and assistant messages: the assistant caller resolves the slot and passes the result down, so the user path stays unaware of a slot it must never render.
**Per-item CAS in a per-session controller.** `@deepseek-ai/dsh-client-ui-feedback` holds one `FeedbackController` per Session, keyed by `MessageId` in a map. A single `list` seeds every control in that Session's transcript. Each mutation sends the version that controller last observed as `ifVersion``null` when it knows of no item, which is exactly the Host's "must not exist" precondition.
The conflict path is where this diverges most from #1010. `MessageFeedbackVersionConflict` carries the authoritative `current` item (or `null`), so a lost race reconciles from the reply itself; #1010 answered every conflict with a blind full refresh. A conflict reporting `current: null` deletes the local entry, which is how a rating removed in another tab disappears here. Mutations serialize on a per-Session tail so a queued operation always compares against the committed version rather than the version read when the click landed.
The list read is deferred to the first hover or focus, not fired on mount, because the controls mount once per settled message in the visible history; a transcript-wide read on mount would fan out one request per message strip. `connection/reset` refreshes only Sessions whose status is no longer `cold`, so a reconnect does not warm Sessions nobody has looked at.
Toggle semantics keep the two verbs honest: re-clicking the recorded rating calls `delete`, switching sides calls `put` and carries any existing note forward, and clearing a message with no known item returns success without a call because it is already in the requested state.
**Remote mounting.** `@deepseek-ai/dsh-api-remotes` now mounts `messageFeedbackRemote` alongside `goalsRemote` and composes both disposers in reverse order. The generated `./remote` artifact already existed in #2217's package exports, so no codegen change was needed; the client calls `ctx.remote.messageFeedback` and never touches the transport. Business results cross this boundary as the ordinary tagged union — the gateway throws only on transport failure — so the controller pattern-matches `ok` and translates a throw into the same settled result shape the controls already render.
## Alternatives considered
**Reuse `conversation.chat.turnTail` instead of a new slot.** Rejected: `turnTail` is a chain keyed on the Turn and carries `TurnTailOwnerProps {turn, seq, openFile}`, which addresses a Turn boundary rather than a message identity. Feedback needs `MessageId`, and a chain is selector-routed one-at-a-time where the action strip is genuinely a list of independent contributors.
**Put `messageId` on the chat node's `id` field.** Rejected: that id is `"${turn}:${step}"` and is load-bearing for keyed dispatch and stable React keys. Overloading it would couple node identity to model output identity, and a message id is not unique per node anyway once replacement-origin events exist.
**Keep #1010's session-wide revision.** Not available: the merged Host contract is per-item `ifVersion`. Even as a client-side simplification it would be worse — one Session revision makes unrelated per-message edits conflict, which is the precise problem #2217's Agent Note records as the reason for per-item versions.
**Rebase #1010.** Rejected after inspection: 102 files, `mergeable: false`, a duplicate backend and RPC layer that #2217 supersedes under different names, and two directory renames since. Only its ~1,400-line UI layer had residual value, and that layer called `feedback.upsert` with a revision it no longer has. Rewriting the UI against the merged contract was less work than reconciling the branch, and the closing comment on #1010 records that reasoning.
## Consequences
The Web GUI records per-message ratings and notes. #1326's user-visible half now exists; the issue was reopened because the backend merge had closed it while no entry point existed.
`AssistantMessageNode.messageId` is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls today, even though trajectory and waterfall nodes now carry the same id.
Feedback stays invisible to the model — the sidecar reaches neither the Session log, model context, nor telemetry — so the package's Model Experience is an audited `none` entry rather than a structured block.
The sidecar publishes no live frames, so a second tab's rating surfaces on reconnect or on the next conflict reply, not immediately. The note editor does not pre-check `maxNoteBytes` (8192 in the Web bundle), so an oversized note fails on save with `note-too-large` rather than while typing.
Twenty-four existing Web UI snapshots gained the two rating buttons across 27 assistant messages, confirming the strip reaches every settled assistant message in the shipped composition rather than only the fixture under test.
A dedicated Web E2E covers rate, note, reload restore, and retract against the shipped bundle. It has to hover the unrated control after a reload before asserting the restored state, because the deferred list read is what makes the sidecar value appear — the test documents that ordering rather than working around it. Sabotaging the controller's list restore makes the spec fail, so the durability assertion has teeth.
@@ -0,0 +1,57 @@
# Agent Note:消息反馈的 Web 界面
Status: implemented
[English](2026-08-11-message-feedback-web-surface.md) | 中文
## 问题
[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) 交付了持久化的消息反馈 sidecar 及其三个 Host Remote 方法,但它明确只做后端:没有任何客户端包消费 `messageFeedback.list``put``delete`,因此 Web GUI 无法记录评价。它的 Agent Note 把「客户端 Remote aggregate 挂载与 UI」留给了另一个负责人。Issue #1326 要求的正是 Web 界面,却在该后端合并时被关闭,而用户可见的那一半并不存在。
更早的全栈尝试 [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010) 带有 UI 层,但它基于自己的后端、形状不同:整个 Session 一个 `revision` 做 compare-and-swapRPC 名为 `feedback.upsert`#2217 最终交付的是逐条 `ifVersion``messageFeedback.put`,因此 #1010 的 controller 逻辑不再匹配契约;它的分支在结构上也已漂移(改动了 `packages/cordis/`,该目录已重命名为 `packages/self-modification/`;新增的顶层 `packages/session-feedback/` 与整合后的 `packages/feedback/` 冲突)。它作为 superseded 关闭,而不是 rebase。
任何 UI 的阻塞缺口在于浏览器无法指名一个反馈目标。Host 只接受以 `MessageId` 寻址的 append 来源 `assistant/message`,但 `AssistantMessageNode`——客户端表示已完成 assistant 输出的节点——只携带 `seq``turn``step`,没有消息身份。只有 `SteeringMessageNode``messageId`
## 决策
三个接缝,各自归属于其权威已经所在的位置。
**客户端节点中的消息身份。** `AssistantMessageNode` 增加可选的 `messageId`,在该节点由已完成的 `assistant/message` 物化时从 `event.data.message.id` 复制。它在被中断冻结的部分输出上保持缺失——那些从未完成、不指向任何持久消息——在 trajectory 布局为未完成部分输出构造的合成哨兵上同样缺失。该字段之所以可选,正是为了让这两种情况无法被表示为反馈目标,而不是用占位值掩盖过去。`ui-conversation``ui-trajectory` 各自物化自己的该节点副本,因此两条「已完成」分支都做了更新;「被中断」分支被有意保留原样。这与 Host 自身的目标规则一致——它按 `isAppendSurfaceEvent` 过滤——因此客户端与 Host 在「什么是可寻址的」上取得一致,而不需要共享代码。
**声明式槽位而非直接依赖。** `ui-conversation` 声明 `conversation.chat.assistant-actions`list 类型、session 作用域、owner 为 `{messageId}`),并把它授权为 `turn-tail` 节点渲染器的第二个子项,与既有的 `conversation.chat.turnTail` 链并列。`TurnTailNodeView` 渲染它,并通过新的 `extraActions` prop 把结果传入 `MessageIconActions`,位置在复制与分支之间。当 `messageId` 缺失时渲染点整体跳过该槽位,因此被中断的 Turn 不显示任何控件。反馈包因此只贡献一个 entry,从不引入 conversation 的实现;当该插件从 `cordis.yml` 组装中移除时,这条操作栏以零成本渲染为空。
`extraActions` 是一个 `ReactNode` prop 而不是第二个 render-slot 洞,因为 `MessageIconActions` 是用户消息与 assistant 消息共享的外壳:由 assistant 一侧解析槽位并把结果向下传递,用户路径则对这个它永远不该渲染的槽位保持无感。
**per-session controller 中的逐条 CAS。** `@deepseek-ai/dsh-client-ui-feedback` 为每个 Session 持有一个 `FeedbackController`,以 `MessageId` 为键存入 map。一次 `list` 为该 Session 转录中的所有控件播种。每次 mutation 发送该 controller 最后观察到的版本作为 `ifVersion`——当它不知道任何条目时为 `null`,这正是 Host 的「必须不存在」前置条件。
冲突路径是与 #1010 分歧最大的地方。`MessageFeedbackVersionConflict` 携带权威的 `current` 条目(或 `null`),因此竞争失败方直接从回复本身收敛;#1010 对每次冲突都以一次盲目的全量刷新作答。报告 `current: null` 的冲突会删除本地条目,这就是在另一个标签页中被移除的评价在此处消失的方式。mutation 在 per-Session 的尾部串行化,因此排队中的操作总是与已提交的版本比较,而不是与点击落下那一刻读到的版本比较。
list 读取被推迟到首次 hover 或 focus,而不是在 mount 时触发,因为控件会为可见历史中每条已结算消息各 mount 一次;在 mount 时做全转录读取会导致每条消息栏各发一个请求。`connection/reset` 只刷新状态不再是 `cold` 的 Session,因此重连不会预热没人看过的 Session。
切换语义让两个动词保持诚实:再次点击已记录的评价调用 `delete`,切换到另一侧调用 `put` 并携带已有备注,而对没有已知条目的消息执行清除会直接返回成功且不发起调用,因为它已处于被请求的状态。
**Remote 挂载。** `@deepseek-ai/dsh-api-remotes` 现在把 `messageFeedbackRemote``goalsRemote` 并列挂载,并以相反顺序组合两个 disposer。生成的 `./remote` 产物在 #2217 的包导出中已存在,因此不需要 codegen 改动;客户端调用 `ctx.remote.messageFeedback`,从不接触传输层。业务结果以普通的 tagged union 穿过该边界——gateway 只在传输失败时抛出——因此 controller 对 `ok` 做模式匹配,并把抛出翻译为控件已经在渲染的同一种结算结果形状。
## 考虑过的替代方案
**复用 `conversation.chat.turnTail` 而不新增槽位。** 否决:`turnTail` 是以 Turn 为键的链,携带 `TurnTailOwnerProps {turn, seq, openFile}`,寻址的是 Turn 边界而非消息身份。反馈需要 `MessageId`,而链是选择器路由的一次一个,操作栏则确实是一组互相独立的贡献者的列表。
**把 `messageId` 放到 chat 节点的 `id` 字段上。** 否决:该 id 是 `"${turn}:${step}"`,且承载着 keyed dispatch 与稳定 React key 的作用。重载它会把节点身份与模型输出身份耦合起来,而且一旦存在 replacement 来源的事件,消息 id 本身在每个节点上也并非唯一。
**保留 #1010 的 session 级 revision。** 不可行:已合并的 Host 契约是逐条 `ifVersion`。即便作为客户端侧的简化也更糟——单一 Session revision 会让互不相关的逐条编辑相互冲突,而这正是 #2217 的 Agent Note 记录的采用逐条版本的原因。
**Rebase #1010。** 经检查后否决:102 个文件、`mergeable: false`、一个被 #2217 以不同名称取代的重复后端与 RPC 层,以及此后的两次目录重命名。只有其约 1400 行的 UI 层有残余价值,而该层调用的 `feedback.upsert` 及其 revision 已不复存在。基于已合并的契约重写 UI 比调和该分支更省力,#1010 的关闭评论记录了这一理由。
## 结果
Web GUI 可以记录逐条消息的评价与备注。#1326 中用户可见的那一半现在存在了;该 Issue 之所以被重开,是因为后端合并在没有任何入口存在的情况下关闭了它。
`AssistantMessageNode.messageId` 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。今天只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。
反馈对模型保持不可见——该 sidecar 既不进入 Session 日志、也不进入模型上下文与 telemetry——因此该包的 Model Experience 是一条经审计的 `none` 条目,而不是结构化区块。
该 sidecar 不发布实时帧,因此第二个标签页的评价会在重连时或下一次冲突回复时才浮现,而不是立即。备注编辑器不预先校验 `maxNoteBytes`Web bundle 中为 8192),因此过大的备注会在保存时以 `note-too-large` 失败,而不是在输入过程中。
24 个既有 Web UI 快照在 27 条 assistant 消息上获得了这两个评价按钮,确认这条操作栏在已发布的组装中触达每条已结算的 assistant 消息,而不仅是被测试的那个 fixture。
一个专门的 Web E2E 针对已发布 bundle 覆盖评分、备注、重载恢复与撤回。它必须在重载后先 hover 未评分的控件,再断言恢复后的状态,因为正是那次延迟的 list 读取让 sidecar 的值出现——该测试记录了这一顺序,而不是绕开它。把 controller 的 list 恢复逻辑破坏掉会让该规格失败,因此这条持久性断言是有效的。
@@ -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/process/2026-07-23-portable-required-pull-request-ci.md
2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5
2026-07-23-portable-required-pull-request-ci.zh.md: ee88412d982da5de613d8dfdbdcdd9977c899d2d
2026-07-23-portable-required-pull-request-ci.md: 418f996383ebc08e78cb5b061bfde4b90dd89495
2026-07-23-portable-required-pull-request-ci.zh.md: 1291de991c42e584d7f1b1a5cb2761a783a31539
@@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
## Decision
[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, the Python SDK unit suite, and the [release-shaped Linux x64 Python runtime validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), while the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
@@ -12,9 +12,9 @@ Status: implemented
## 决策
[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26Python SDK 单元测试套件与[发布形态的 Linux x64 Python 运行时验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md),串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e``all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。
项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证`windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e``all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。
当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/testing/2026-08-12-required-python-runtime-pull-request-ci.md
2026-08-12-required-python-runtime-pull-request-ci.md: 2f5dcac17262bd885049221620a4d9708b083faa
2026-08-12-required-python-runtime-pull-request-ci.zh.md: 66c81f70d1bf25355425030883ccc4307efbb1ce
@@ -0,0 +1,31 @@
# Agent Note: Required Python runtime pull-request validation
Status: implemented
English | [中文](2026-08-12-required-python-runtime-pull-request-ci.zh.md)
## Problem
Ordinary pull-request CI runs the complete Python SDK pytest suite against fake runtime peers, while Node snapshots exercise different clients and expected outputs. The real Python client, packaged JSON-RPC executable, executable-specific snapshot, release-shaped wheels, and clean installation meet only in the optional single-executable or Python release workflows. A runtime event change or closure change can therefore merge with a stale Python projection or broken wheel path and fail only when someone later builds a Python release candidate.
## Decision
Every pull request has a required `python-runtime` job in [CI](../../../../.github/workflows/ci.yml). It calls the shared [single-executable builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) for `node24-linux-x64` without a path filter and participates in `all checks passed`. The called workflow builds the real executable, runs all keyless Python full-turn and direct-binary scenarios including the committed executable snapshot, builds the SDK and runtime wheels, installs them into a clean virtual environment, checks the executable and native addon's GLIBC requirements, and runs the installed wheels in a manylinux 2.28 container.
The required job and the [Python publication workflow](../process/2026-08-11-python-publication-workflow.md) use the same builder. Its concurrency key includes the caller workflow, so required CI and an explicit full release validation for the same ref do not cancel each other. The complete linux-x64, linux-arm64, and macos-arm64 matrix remains a release validation because platform-independent runtime, SDK, and snapshot behavior needs one merge-blocking native carrier, while architecture-specific executable, addon, wheel-tag, and deployment-target behavior still needs all release targets before publication.
The executable snapshot normalizes opaque session, message, subagent, and workflow-run identifiers before comparison. A newly persisted workflow event therefore changes the reviewed expected output without making a random run identifier part of that output.
## Alternatives considered
**Run the complete native matrix on every pull request.** This duplicates platform-independent full-turn and snapshot behavior across three jobs and consumes ARM64 Linux and macOS capacity on every change. The publication workflow retains that evidence at the point where all three artifacts are required.
**Run the snapshot against the development Node carrier.** This catches protocol and event projection drift but does not prove pkg assembly, the deployed runtime closure, native addon staging, wheel construction, exact dependency pins, or clean installation. The required Linux executable path covers the published path directly.
**Select the job with path filters or labels.** Python behavior depends on shared agent, session, workflow, subagent, plugin-loading, and packaging code outside `python/`. An incomplete dependency filter recreates the delayed failure, and a label leaves the evidence optional.
## Consequences
Every pull request pays for one standard-hosted Linux executable and wheel build, and `all checks passed` waits for it. This makes the first-party Python distribution a merge-time contract and reuses the release implementation instead of maintaining a smaller substitute pipeline.
One required architecture cannot detect macOS or Linux ARM64 packaging regressions. Explicit full release validation remains mandatory before publication and owns those platform-specific results.
@@ -0,0 +1,31 @@
# Agent Note: 必需的 Python 运行时拉取请求验证
Status: implemented
[English](2026-08-12-required-python-runtime-pull-request-ci.md) | 中文
## 问题
普通拉取请求 CI 会针对 fake 运行时对端执行完整的 Python SDK pytest 套件,而 Node 快照使用不同的客户端与预期输出。真实 Python 客户端、打包后的 JSON-RPC 可执行文件、exe 专用快照、发布形态 wheel 包与干净安装只在可选的单文件可执行程序工作流或 Python 发布工作流中汇合。因此,运行时事件或闭包发生变化后,陈旧的 Python 投影或损坏的 wheel 包路径仍可能合并,直到后续有人构建 Python 发布候选版本时才失败。
## 决策
每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括检入的 exe 快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。
必需作业与 [Python 发布工作流](../process/2026-08-11-python-publication-workflow.md)共用同一构建器。其并发键包含调用方工作流,因此同一 ref 上的必需 CI 与显式完整发布验证不会互相取消。完整的 linux-x64、linux-arm64 和 macos-arm64 矩阵仍属于发布验证:平台无关的运行时、SDK 与快照行为只需要一个阻断合并的原生载体,而架构相关的可执行文件、addon、wheel 包标签与部署目标行为在发布前仍需要全部发布目标验证。
exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。
## 曾考虑的替代方案
**每个拉取请求都运行完整原生矩阵。** 这会在三个作业中重复平台无关的完整轮次与快照行为,并让每项改动都消耗 ARM64 Linux 和 macOS 容量。Python 发布工作流在需要全部三个产物的环节保留这部分证据。
**针对开发用 Node 载体运行快照。** 这可以捕获协议与事件投影漂移,但不能证明 pkg 组装、部署后的运行时闭包、原生 addon 暂存、wheel 包构建、精确依赖版本与干净安装。必需的 Linux exe 路径直接覆盖发布路径。
**通过路径过滤或标签选择该作业。** Python 行为依赖 `python/` 之外共享的 agent、会话、工作流、subagent、插件加载与打包代码。不完整的依赖过滤会再次造成延迟发现,标签则会让证据保持可选。
## 后果
每个拉取请求都会承担一次标准托管 Linux exe 与 wheel 包构建,`all checks passed` 也会等待该作业。这使第一方 Python 分发成为合并时约定,并复用发布实现,而不维护一条更小的替代流水线。
单一必需架构无法检测 macOS 或 Linux ARM64 打包回归。发布前仍必须执行显式完整发布验证,并由该验证负责这些平台特定结果。
@@ -20,6 +20,11 @@ on:
type: boolean
required: false
default: false
ci:
description: Run as the required Linux x64 Python runtime pull-request check.
type: boolean
required: false
default: false
workflow_dispatch:
inputs:
targets:
@@ -35,8 +40,9 @@ on:
concurrency:
# Keep the called workflow distinct from its caller's concurrency group;
# github.workflow identifies the caller inside a reusable workflow.
group: build-single-exe-${{ github.ref }}
# github.workflow identifies the caller inside a reusable workflow and keeps
# an ordinary CI run from cancelling a full release validation on the same ref.
group: build-single-exe-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
@@ -52,7 +58,7 @@ jobs:
# construct the matrix before the dependent jobs.
plan:
name: plan targets
if: inputs.release || github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
if: inputs.ci || inputs.release || github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
+13 -1
View File
@@ -300,6 +300,18 @@ jobs:
- name: Run complete keyless Python suite
run: uv run --python 3.10 --group test --project python/sdk pytest
# One native target makes the complete release-shaped Python path required
# without duplicating platform-independent behavior across the release matrix.
# The reusable builder owns the executable, snapshot, wheel, clean-install,
# GLIBC, and manylinux checks; release validation retains all native targets.
python-runtime:
if: github.event_name == 'pull_request'
name: python runtime / release-shaped Linux x64
uses: ./.github/workflows/build-exe-for-python-sdk.yml
with:
targets: node24-linux-x64
ci: true
# The required pull-request Windows signal: the two blocking win32 surfaces
# (workspace build, production site) execute with real, checksum-verified
# Windows Node under Wine on standard hosted Linux. The independent
@@ -895,7 +907,7 @@ jobs:
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-latest' }}
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows]
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows]
if: always() && github.event_name == 'pull_request'
steps:
- name: Fail if any needed job did not succeed
+2 -2
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 README.md
README.md: 37c9bc77cde1e2259b4233d4dc37f9c8b1264a96
README.zh.md: e849c8cda6867c711e4fea4f464cfbacac5c4693
README.md: c471b1a2470a5f2161a7950d757c5784a8e27aee
README.zh.md: 16c4d4adae2c3b4e0ecb48380a05a4f9643f9d45
+1 -1
View File
@@ -10,7 +10,7 @@ It uses an architecture where **everything is a plugin**.
DeepSeek Harness is under internal testing. Features and interfaces may change.
The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.
Session Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously; `FULL` also enables dsh-sdk command telemetry reporting an anonymous ID, the command result, and redacted project configuration. Send feedback through the internal WeChat group.
## Run
+1 -1
View File
@@ -10,7 +10,7 @@ DeepSeek Harness`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。
为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。
Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。
## 运行
+2 -2
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
+2 -2
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.
+2 -2
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(智能体)沙箱之外的受信任可执行代码。
+1 -1
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
+1 -1
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()
})
+121
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([])
})
})
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -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
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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 "继续"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1
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",
+2 -2
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 docs/config-catalog.md
config-catalog.md: bd5b646d119828373d5f8c1a5160f2e85c049ce8
config-catalog.zh.md: 33d5aa44591c4df9658a286366a2ff993e034b0e
config-catalog.md: 369490ec41480b8c46355f0edc3eb97f2f0c76cb
config-catalog.zh.md: a735d7021d44dda3cffa49c31430249f70431720
+2 -1
View File
@@ -1605,7 +1605,7 @@ Requires: `sessions`
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
@@ -2809,6 +2809,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
- `@deepseek-ai/dsh-client-ui-directory-picker` ([`packages/client/ui-directory-picker/src/index.ts`](../packages/client/ui-directory-picker/src/index.ts))
- `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
- `@deepseek-ai/dsh-client-ui-feedback` ([`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts))
- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts))
+2 -1
View File
@@ -1607,7 +1607,7 @@ export interface Config {
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
@@ -2810,6 +2810,7 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-deliverables`[`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)
- `@deepseek-ai/dsh-client-ui-directory-picker`[`packages/client/ui-directory-picker/src/index.ts`](../packages/client/ui-directory-picker/src/index.ts)
- `@deepseek-ai/dsh-client-ui-directory-picker-native`[`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)
- `@deepseek-ai/dsh-client-ui-feedback`[`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts)
- `@deepseek-ai/dsh-client-ui-goal`[`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)
- `@deepseek-ai/dsh-client-ui-layout`[`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)
- `@deepseek-ai/dsh-client-ui-model`[`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)
+2 -2
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 docs/module-graph.md
module-graph.md: 9ca575137f3ebdddbc77e89c3b3b97da90c94843
module-graph.zh.md: 6978ff30b7e8e927a0d0a10dd7b06cfbbeb1f7e9
module-graph.md: 56e029df192f28a787748b12074ee4dfe67d1c58
module-graph.zh.md: 839c5edf758a3076874643cb6bdbd91954ca369e
+14 -1
View File
@@ -155,6 +155,7 @@ flowchart TD
pkg_client_ui_deliverables["client-ui-deliverables"]
pkg_client_ui_directory_picker["client-ui-directory-picker"]
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_feedback["client-ui-feedback"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_model["client-ui-model"]
@@ -650,6 +651,7 @@ flowchart TD
pkg_api_remotes --> pkg_host_plugin_inventory
pkg_api_remotes --> pkg_invariants
pkg_api_remotes --> pkg_llm
pkg_api_remotes --> pkg_message_feedback
pkg_api_remotes --> pkg_session
pkg_api_remotes --> pkg_session_persistence
pkg_api_remotes --> pkg_settings
@@ -1260,6 +1262,16 @@ flowchart TD
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_feedback --> pkg_api_remotes
pkg_client_ui_feedback --> pkg_client_connection
pkg_client_ui_feedback --> pkg_client_locale
pkg_client_ui_feedback --> pkg_client_runtime
pkg_client_ui_feedback --> pkg_client_ui_conversation
pkg_client_ui_feedback --> pkg_client_ui_primitives
pkg_client_ui_feedback --> pkg_client_ui_slots
pkg_client_ui_feedback --> pkg_invariants
pkg_client_ui_feedback --> pkg_message_feedback
pkg_client_ui_feedback --> pkg_type_meta
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
@@ -1457,7 +1469,7 @@ flowchart TD
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
@@ -1552,6 +1564,7 @@ flowchart TD
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`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` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`message-feedback`](../packages/feedback/message-feedback), [`type-meta`](../packages/typert/type-meta) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
+14 -1
View File
@@ -157,6 +157,7 @@ flowchart TD
pkg_client_ui_deliverables["client-ui-deliverables"]
pkg_client_ui_directory_picker["client-ui-directory-picker"]
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_feedback["client-ui-feedback"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_model["client-ui-model"]
@@ -652,6 +653,7 @@ flowchart TD
pkg_api_remotes --> pkg_host_plugin_inventory
pkg_api_remotes --> pkg_invariants
pkg_api_remotes --> pkg_llm
pkg_api_remotes --> pkg_message_feedback
pkg_api_remotes --> pkg_session
pkg_api_remotes --> pkg_session_persistence
pkg_api_remotes --> pkg_settings
@@ -1262,6 +1264,16 @@ flowchart TD
pkg_client_ui_deliverables --> pkg_client_ui_conversation
pkg_client_ui_deliverables --> pkg_client_ui_slots
pkg_client_ui_deliverables --> pkg_invariants
pkg_client_ui_feedback --> pkg_api_remotes
pkg_client_ui_feedback --> pkg_client_connection
pkg_client_ui_feedback --> pkg_client_locale
pkg_client_ui_feedback --> pkg_client_runtime
pkg_client_ui_feedback --> pkg_client_ui_conversation
pkg_client_ui_feedback --> pkg_client_ui_primitives
pkg_client_ui_feedback --> pkg_client_ui_slots
pkg_client_ui_feedback --> pkg_invariants
pkg_client_ui_feedback --> pkg_message_feedback
pkg_client_ui_feedback --> pkg_type_meta
pkg_client_ui_goal --> pkg_api_remotes
pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
@@ -1459,7 +1471,7 @@ flowchart TD
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) |
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
@@ -1554,6 +1566,7 @@ flowchart TD
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`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` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `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-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`message-feedback`](../packages/feedback/message-feedback), [`type-meta`](../packages/typert/type-meta) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
+2 -2
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 docs/subsystems/feedback.md
feedback.md: a0daf47d093f3efb643950c0e124a8db0734fde4
feedback.zh.md: 1163d30a6af5f818ab3be8ff754de20656e7e743
feedback.md: 21398c8e0b28c1d6de223a0c1887b249084f3f26
feedback.zh.md: a149b4c53e4cb22faeab7b4eda74e0b95e30cc53
+11 -1
View File
@@ -201,15 +201,25 @@ The service stores whole Session rows in the `message_feedback` storage domain t
Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain.
## Web surface
[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` contribution, so the plugin calls `ctx.remote.messageFeedback` and never touches the transport.
The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. Reaching that render site required one plumbing change: `AssistantMessageNode` now carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows.
One `FeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read.
## Boundaries and limitations
- The client Remote aggregate mount and UI consumer are separately owned and deferred.
- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee.
- Session persistence has no durable deletion API. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal.
- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization.
- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy.
- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract.
- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary.
- The Web controls appear in the chat view only. The trajectory and waterfall views render no feedback entry even though their assistant nodes carry the same `messageId`.
- The sidecar publishes no live frames, so a second tab's rating becomes visible on reconnect or on the next conflict reply rather than immediately.
- The note editor does not pre-check `maxNoteBytes`; an oversized note fails on save with `note-too-large` rather than while typing.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+11 -1
View File
@@ -201,15 +201,25 @@ type MessageFeedbackDeleteResult =
Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。
## Web 界面
[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。
控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。为抵达该渲染点需要一处管道改动:`AssistantMessageNode` 现在携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 契约允许的更窄。
每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。
## 边界与限制
- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。
- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。
- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。
- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。
- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。
- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。
- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
- Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。
- 该 sidecar 不发布实时帧,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。
- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+4 -2
View File
@@ -66,8 +66,9 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
@@ -81,8 +82,9 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
+3 -1
View File
@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
@@ -11,6 +12,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -57,7 +59,7 @@ export const inject = ['remote']
export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {
@@ -26,6 +26,9 @@
{
"path": "../../goal/goal"
},
{
"path": "../../feedback/message-feedback"
},
{
"path": "../../host/plugin-inventory"
},
+7 -8
View File
@@ -120,14 +120,12 @@
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
# Session telemetry, on for every dsh mode: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
# explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
# session-log records onto OTLP/HTTP logs with no telemetry/record redaction
# rule, so exports are the raw captured copy. The deployment stance, env
# seams, and follow-ups are pinned in the default-off Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
@@ -144,6 +142,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+4 -1
View File
@@ -27,11 +27,14 @@ describe('dsh-base bundle', () => {
)
expect(Array.isArray(parsed)).toBe(true)
// The base layer is one insert list over the empty profile root.
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
const rows = (parsed as { insert?: { id?: string; config?: Record<string, unknown> }[] }[]).flatMap(
patch => patch.insert ?? [],
)
expect(rows.length).toBeGreaterThan(50)
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
expect(rows.find(row => row.id === 'telemetry-otel')?.config?.['mode']).toEqual({
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
})
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1)
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1)
expect(manifest.dependencies).toMatchObject({
+5
View File
@@ -222,6 +222,11 @@
- id: ui-goal
name: '@deepseek-ai/dsh-client-ui-goal'
# Per-message feedback: Like/Dislike plus an optional note in the
# assistant-message action strip, over the messageFeedback Remote.
- id: ui-feedback
name: '@deepseek-ai/dsh-client-ui-feedback'
# Model selection: the /model popupSelect + composer seat over session.models.
- id: ui-model
name: '@deepseek-ai/dsh-client-ui-model'
+1
View File
@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-client-ui-feedback": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
@@ -96,6 +96,12 @@ export interface AssistantTiming {
export interface AssistantMessageNode {
kind: 'assistant'
seq: number
/**
* Stable identity of the finalized model output, carried from the
* `assistant/message` event. Absent on interruption-frozen partials: those
* were never finalized, so they address no durable message.
*/
messageId?: MessageId
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
time: number
turn: number
@@ -1,7 +1,7 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -29,6 +29,11 @@ export interface MessageIconActionsProps {
branchUnavailable?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/**
* Slot-rendered actions owned by independent plugins, placed between the
* built-in copy and branch controls.
*/
extraActions?: ReactNode
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -39,7 +44,8 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
extraActions, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -109,6 +115,7 @@ export function MessageIconActions({
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{extraActions}
{onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
@@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlotChain, t, useSession,
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
}: TurnTailNodeViewProps) {
const data = node.data
const hasLaterChatNode = useSession(snapshot =>
@@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
// Interruption-frozen partials carry no messageId, so they address no
// durable message and contribute no per-message actions.
const messageId = closing.finalNode.messageId
const assistantActions = messageId === undefined
? null
: renderSlot('conversation.chat.assistant-actions', { messageId })
return (
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
{tail}
@@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
t={t}
/>
</div>
@@ -39,7 +39,10 @@ export function registerChatNodeRenderers(ctx: Context): void {
name: 'conversation.chat.node',
key: 'turn-tail',
locale: NS,
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
children: {
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
},
}, TurnTailNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
@@ -11,6 +11,7 @@ import type {
TurnLocation, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type {
@@ -77,6 +78,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* only to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* Action strip attached to one finalized assistant message, rendered
* inside that message's IconActions row. The chat entry owns the render
* site and passes the addressed message identity; contributors add
* per-message actions without importing the conversation implementation.
* Entries render by ascending `order`.
*/
'conversation.chat.assistant-actions': {
kind: 'list'
scope: 'session'
owner: AssistantActionOwnerProps
}
/** Selected Tool call output inside the details panel. */
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
/**
@@ -253,6 +266,16 @@ export interface TurnTailOwnerProps {
openFile: (path: string) => void
}
/**
* Owner currency of the assistant-message action strip: the durable identity
* of the one finalized message the contributed actions address. Only finalized
* messages reach this slot, so the id is always present.
*/
export interface AssistantActionOwnerProps {
/** Stable identity carried from the `assistant/message` event. */
messageId: MessageId
}
/** Hook constrained to business data published on the current Chat Node's Turn. */
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
@@ -152,6 +152,7 @@ function finalNode(
return {
kind: 'assistant',
seq: event.seq,
messageId: event.data.message.id,
time: event.time,
turn: state.turn,
step: state.step,
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-feedback/README.md
README.md: 0187fee61863292ee4e51975efe414a4e41ba066
README.zh.md: 9d225f4632e15a2b54437cbe30d376acb509a156
+25
View File
@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-client-ui-feedback
English | [中文](README.zh.md)
Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
One `FeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history.
Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` carries the `version` this controller last observed, and a `version-conflict` reply carries the authoritative item, so a lost race reconciles from the reply itself instead of refetching the Session. Mutations serialize per Session, so a queued operation always compares against the committed version. Re-clicking the recorded rating retracts the feedback; switching sides carries the existing note forward.
The `/client` exports are the plugin body (`apply`/`inject`), the `FeedbackActions` component, the `FeedbackController` class, and the injected face types.
## Model Experience
None, as feedback is a sidecar that never enters the append-only Session log, the model context, or telemetry; no rating or note is ever visible to the model.
#### KV Cache effect
None; no feedback mutation touches the history tail.
## Known Limitations and Deferred Work
- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The editor does not pre-check the limit, so an oversized note fails on save rather than while typing.
- **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the sidecar publishes no live frames.
- **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes now carry the same `messageId`.
+25
View File
@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-client-ui-feedback
[English](README.md) | 中文
单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。
变更通过 `ctx.remote.messageFeedback` 提交,按条目的 compare-and-set 由 Host 负责。每次 `put``delete` 都携带本 controller 最后观察到的 `version``version-conflict` 响应会带回权威条目,因此竞争失败时直接用该响应对账,无需重新拉取整个 Session。变更按 Session 串行,排队中的操作总是与已提交的版本比较。再次点击已记录的评分会撤回反馈;切换到另一侧会保留已有备注。
`/client` 导出插件本体(`apply`/`inject`)、`FeedbackActions` 组件、`FeedbackController` 类以及注入面类型。
## 模型体验
无。反馈是 sidecar,不进入 append-only 的 Session 日志、模型上下文或遥测;任何评分与备注对模型都不可见。
#### KV Cache 影响
无;任何反馈变更都不触碰历史尾部。
## 已知限制与暂缓事项
- **备注大小是 Host 策略** —— 部署方配置 `maxNoteBytes`Web bundle 中为 8192),超长备注由 Host 以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。
- **无跨标签页推送** —— 另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;该 sidecar 不发布实时帧。
- **仅限对话视图** —— trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点现在也带有相同的 `messageId`
+86
View File
@@ -0,0 +1,86 @@
{
"name": "@deepseek-ai/dsh-client-ui-feedback",
"description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-feedback"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,108 @@
/* Per-message feedback controls. The rating buttons mirror the shared message
IconActions chrome so the strip reads as one row; the note editor is an
inline expansion anchored to the same row. */
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.action:disabled {
cursor: default;
opacity: 0.4;
}
/* A recorded rating stays legible without hover, so the signal survives a
pointer leaving the row. */
.action[data-active] {
color: var(--dsw-alias-label-primary);
}
.noteOpen {
max-width: 220px;
overflow: hidden;
padding: 0 8px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 28px;
white-space: nowrap;
text-overflow: ellipsis;
cursor: pointer;
}
.noteOpen:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.noteEditor {
display: inline-flex;
align-items: flex-start;
gap: 6px;
}
.noteInput {
width: 260px;
padding: 6px 8px;
border: 1px solid var(--dsw-alias-border-secondary);
border-radius: 8px;
background: var(--dsw-alias-bg-primary);
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 13px;
resize: vertical;
}
.noteSave,
.noteCancel {
height: 28px;
padding: 0 10px;
border: none;
border-radius: 14px;
font-size: 13px;
cursor: pointer;
}
.noteSave {
background: var(--dsw-alias-interactive-bg-primary);
color: var(--dsw-alias-label-inverse);
}
.noteSave:disabled {
cursor: default;
opacity: 0.4;
}
.noteCancel {
background: transparent;
color: var(--dsw-alias-label-tertiary);
}
.noteCancel:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.failure {
padding-left: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 28px;
}
@@ -0,0 +1,153 @@
/**
* Per-message feedback controls: a Like/Dislike pair plus an optional note.
* Rendered inside the assistant message's IconActions row, so the buttons
* reuse that row's chrome and sit between copy and branch.
* @module @deepseek-ai/dsh-client-ui-feedback/client/FeedbackActions
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import {
IconDislikeOutline16, IconLikeOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
import type { FeedbackActionProps } from './slots.ts'
import css from './FeedbackActions.module.css'
/**
* One message's feedback controls.
* @param props - the owner's message identity, the injected verbs, and the
* shared feedback hook.
* @returns the rating buttons, plus the note editor while it is open.
*/
export function FeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: FeedbackActionProps) {
const item = useFeedback(view => view.items.get(messageId))
const loadFailed = useFeedback(view => view.status === 'error')
const rating = item?.rating
const [noteOpen, setNoteOpen] = useState(false)
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
// The controls mount for every settled message in the transcript, so the
// Session's feedback is read once on first hover/focus rather than on mount.
const seeded = useRef(false)
const seed = useCallback(() => {
if (seeded.current) return
seeded.current = true
void ensure()
}, [ensure])
const alive = useRef(true)
useEffect(() => () => { alive.current = false }, [])
const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => {
if (!alive.current) return
setPending(false)
if (result.ok) {
setFailure(null)
return
}
setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic'))
}, [t])
const onRate = useCallback((next: MessageFeedbackRating) => {
setPending(true)
setFailure(null)
// The controller decides retract-vs-replace from the committed item, so a
// click that lands before the first list read still toggles the stored
// value instead of this render's empty view.
setNoteOpen(false)
void toggle(messageId, next).then(settle)
}, [messageId, settle, toggle])
// The rating is a parameter because only the note editor's render site can
// prove one is recorded; that removes an unreachable undefined guard here.
const onSaveNote = useCallback((current: MessageFeedbackRating) => {
const trimmed = draft.trim()
setPending(true)
setFailure(null)
// An emptied editor removes the note explicitly; `rate` alone preserves a
// stored note, so it cannot express deletion.
const settled = trimmed.length === 0
? clearNote(messageId)
: rate(messageId, current, trimmed)
void settled.then((result) => {
settle(result)
if (result.ok && alive.current) setNoteOpen(false)
})
}, [clearNote, draft, messageId, rate, settle])
const openNote = useCallback(() => {
setDraft(item?.note ?? '')
setNoteOpen(true)
}, [item?.note])
const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')
return (
<>
<Tooltip label={likeLabel} side="bottom">
<button
type="button"
className={css.action}
aria-label={likeLabel}
aria-pressed={rating === 'positive'}
data-active={rating === 'positive' || undefined}
disabled={pending}
onFocus={seed}
onPointerEnter={seed}
onClick={() => { onRate('positive') }}
>
<IconLikeOutline16 />
</button>
</Tooltip>
<Tooltip label={dislikeLabel} side="bottom">
<button
type="button"
className={css.action}
aria-label={dislikeLabel}
aria-pressed={rating === 'negative'}
data-active={rating === 'negative' || undefined}
disabled={pending}
onFocus={seed}
onPointerEnter={seed}
onClick={() => { onRate('negative') }}
>
<IconDislikeOutline16 />
</button>
</Tooltip>
{rating !== undefined && !noteOpen && (
<button type="button" className={css.noteOpen} onClick={openNote}>
{item?.note === undefined ? t('note.open') : item.note}
</button>
)}
{rating !== undefined && noteOpen && (
<span className={css.noteEditor}>
<textarea
className={css.noteInput}
aria-label={t('note.aria')}
placeholder={t('note.placeholder')}
value={draft}
rows={2}
onChange={(event) => { setDraft(event.target.value) }}
/>
<button
type="button"
className={css.noteSave}
disabled={pending}
onClick={() => { onSaveNote(rating) }}
>
{t('note.save')}
</button>
<button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
{t('note.cancel')}
</button>
</span>
)}
{failure === null && loadFailed && (
<span className={css.failure} role="status">{t('error.load')}</span>
)}
{failure !== null && <span className={css.failure} role="status">{failure}</span>}
</>
)
}
@@ -0,0 +1,377 @@
/**
* Browser-local object layer over one Session's durable message-feedback
* sidecar. The Host owns per-item compare-and-set: every mutation carries the
* version this controller last observed, and a `version-conflict` reply carries
* the authoritative item, so a lost race reconciles from the reply itself
* instead of refetching the whole Session.
* @module @deepseek-ai/dsh-client-ui-feedback/client/controller
*/
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
MessageFeedbackDeleteResult,
MessageFeedbackItem,
MessageFeedbackListResult,
MessageFeedbackPutResult,
MessageFeedbackRating,
} from '@deepseek-ai/dsh-message-feedback/types'
/**
* The three Remote calls this controller needs. The generated face wraps every
* business result in {@link RemoteResult}: a carrier failure arrives as the
* `ok: false` branch rather than a rejection, so this controller reads one
* envelope and never wraps a call to recover a transport error.
*/
export interface MessageFeedbackRemote {
list: (request: { sessionId: SessionId }) => Promise<RemoteResult<MessageFeedbackListResult>>
put: (request: {
sessionId: SessionId
messageId: MessageId
rating: MessageFeedbackRating
note?: string
ifVersion: MessageFeedbackItem['version'] | null
}) => Promise<RemoteResult<MessageFeedbackPutResult>>
delete: (request: {
sessionId: SessionId
messageId: MessageId
ifVersion: MessageFeedbackItem['version']
}) => Promise<RemoteResult<MessageFeedbackDeleteResult>>
}
/** Load state of the one list read that seeds every per-message control. */
export type FeedbackStatus = 'cold' | 'loading' | 'ready' | 'error'
/** Immutable view published to every per-message control in one Session. */
export interface FeedbackView {
status: FeedbackStatus
/** Current item per message, keyed by the addressed message id. */
items: ReadonlyMap<MessageId, MessageFeedbackItem>
/** Reason the last load failed, cleared by the next successful load. */
error: string | null
}
/** Settled action shape rendered by the message-level controls. */
export type FeedbackActionResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } }
// `Object.freeze` does not protect a Map: `set`/`delete` write internal slots,
// not properties. Immutability here is by discipline instead — the view type is
// ReadonlyMap and every publish hands over a freshly built Map that this class
// keeps no mutable reference to.
const EMPTY_ITEMS: ReadonlyMap<MessageId, MessageFeedbackItem> = new Map()
const INITIAL_VIEW: FeedbackView = Object.freeze({
status: 'cold',
items: EMPTY_ITEMS,
error: null,
})
const OK: FeedbackActionResult = Object.freeze({ ok: true })
const DISPOSED: FeedbackActionResult = Object.freeze({
ok: false,
error: Object.freeze({ code: 'disposed', message: 'feedback controller is disposed' }),
})
/** Human-readable text for one business failure code. */
function describe(code: string): string {
switch (code) {
case 'session-not-found': return 'this session is no longer persisted'
case 'target-not-found': return 'this message is not a persisted assistant message'
case 'version-conflict': return 'feedback changed elsewhere'
case 'note-blank': return 'a note must contain a non-whitespace character'
case 'note-too-large': return 'the note is too long'
default: return code
}
}
/** Build the rejected branch for one business failure code. */
function fail(code: string): FeedbackActionResult {
return { ok: false, error: { code, message: describe(code) } }
}
/** Carrier failure rendered with the Host-supplied code and message. */
function carrierFailure(error: { code: string; message: string }): FeedbackActionResult {
return { ok: false, error: { code: error.code, message: error.message } }
}
/**
* Per-session feedback object layer. One instance backs every per-message
* control in that Session, so a single list read seeds them all.
*/
export class FeedbackController implements HostObservable<FeedbackView> {
private view = INITIAL_VIEW
private readonly listeners = new Set<() => void>()
private loadPromise: Promise<FeedbackActionResult> | null = null
private operationTail: Promise<void> = Promise.resolve()
private disposed = false
/**
* @param remote - the messageFeedback Remote namespace.
* @param sessionId - Session owning every addressed assistant message.
*/
constructor(
private readonly remote: MessageFeedbackRemote,
private readonly sessionId: SessionId,
) {}
/** Return the cached immutable view. */
getSnapshot = (): FeedbackView => this.view
/** Subscribe to view replacement. */
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Load once; a failed load stays retryable.
* @returns the settled load result, shared by concurrent callers.
*/
ensure(): Promise<FeedbackActionResult> {
if (this.view.status === 'ready') return Promise.resolve(OK)
return this.refresh()
}
/**
* Re-read the authoritative list, collapsing concurrent callers onto one
* in-flight read.
*
* This is the unserialized read used to seed a cold controller, where no
* mutation can be in flight yet. A reconnect must use {@link resync} instead:
* an unserialized list response can otherwise arrive after a newer mutation's
* reply and overwrite the version that mutation just committed.
* @returns the settled reload result.
*/
refresh(): Promise<FeedbackActionResult> {
if (this.loadPromise !== null) return this.loadPromise
this.publish({ status: 'loading', items: this.view.items, error: null })
const pending = this.load()
this.loadPromise = pending
return pending.finally(() => { this.loadPromise = null })
}
/**
* Re-read the list behind this Session's queued mutations, so a reconnect
* cannot resurrect a version an in-flight mutation already replaced.
* @returns the settled reload result.
*/
resync(): Promise<FeedbackActionResult> {
// seed: false — this operation *is* the read, so pre-seeding would either
// short-circuit it (status already ready) or run it twice.
return this.mutate(() => this.refresh(), { seed: false })
}
/**
* Create or replace feedback for one message, comparing against the version
* this controller last observed.
*
* The note is resolved here rather than by the caller: `mutate` awaits the
* one list read first, so this body always sees the committed item, while a
* control that rendered before that read completed would still be holding
* `undefined`. Omitting `note` therefore keeps whatever is stored; only
* {@link clearNote} removes one.
* @param messageId - target assistant message.
* @param rating - desired judgment.
* @param note - replacement explanation; omitted keeps the stored note.
* @returns the settled mutation result.
*/
rate(
messageId: MessageId,
rating: MessageFeedbackRating,
note?: string,
): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
return await this.putCommitted(messageId, rating, note ?? observed?.note, observed)
})
}
/**
* Replace one message's rating with the opposite judgment, or retract it when
* the committed rating already matches. The decision reads the committed item
* inside the serialized mutation, so a click that lands before the first list
* read still toggles against the stored value rather than the empty view a
* cold control rendered.
* @param messageId - target assistant message.
* @param rating - the judgment the human asked for.
* @returns the settled mutation result.
*/
toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed?.rating === rating) return await this.deleteCommitted(messageId, observed)
return await this.putCommitted(messageId, rating, observed?.note, observed)
})
}
/**
* Drop the note while keeping the rating. Absent feedback needs no call.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clearNote(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined || observed.note === undefined) return OK
return await this.putCommitted(messageId, observed.rating, undefined, observed)
})
}
/**
* Remove feedback for one message. A message with no known item is already
* in the requested state, so no call is made.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clear(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined) return OK
return await this.deleteCommitted(messageId, observed)
})
}
/** Commit one put against the observed version and reconcile a conflict. */
private async putCommitted(
messageId: MessageId,
rating: MessageFeedbackRating,
note: string | undefined,
observed: MessageFeedbackItem | undefined,
): Promise<FeedbackActionResult> {
const carried = await this.remote.put({
sessionId: this.sessionId,
messageId,
rating,
...(note === undefined ? {} : { note }),
ifVersion: observed?.version ?? null,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, result.value)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Commit one delete against the observed version and reconcile a conflict. */
private async deleteCommitted(
messageId: MessageId,
observed: MessageFeedbackItem,
): Promise<FeedbackActionResult> {
const carried = await this.remote.delete({
sessionId: this.sessionId,
messageId,
ifVersion: observed.version,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, null)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Drop subscribers and refuse further work when the owning fiber unloads. */
dispose(): void {
this.disposed = true
this.listeners.clear()
}
/** Fetch the whole sidecar and publish it as the seeded view. */
private async load(): Promise<FeedbackActionResult> {
try {
const carried = await this.remote.list({ sessionId: this.sessionId })
if (this.disposed) return OK
if (!carried.ok) {
this.publish({ status: 'error', items: this.view.items, error: carried.error.message })
return carrierFailure(carried.error)
}
const result = carried.value
if (!result.ok) {
this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) })
return fail(result.error.code)
}
const items = new Map<MessageId, MessageFeedbackItem>()
for (const item of result.value.items) items.set(item.messageId, item)
this.publish({ status: 'ready', items, error: null })
return OK
} catch (error) {
if (this.disposed) return OK
const message = error instanceof Error ? error.message : 'message feedback list failed'
this.publish({ status: 'error', items: this.view.items, error: message })
return { ok: false, error: { code: 'transport', message } }
}
}
/**
* Serialize one mutation behind this Session's prior mutation so queued
* operations always compare against the committed version, and translate a
* transport throw into the same settled shape the controls already render.
*/
private mutate(
operation: () => Promise<FeedbackActionResult>,
options: { readonly seed?: boolean } = {},
): Promise<FeedbackActionResult> {
const guarded = async (): Promise<FeedbackActionResult> => {
if (this.disposed) return DISPOSED
if (options.seed !== false) {
const loaded = await this.ensure()
if (!loaded.ok) return loaded
// Disposal can land while the seeding read is in flight; without this
// second check the fiber would still reach the wire after unloading.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
if (this.disposed) return DISPOSED
}
try {
return await operation()
} catch (error) {
return {
ok: false,
error: {
code: 'transport',
message: error instanceof Error ? error.message : 'message feedback mutation failed',
},
}
}
}
const result = this.operationTail.then(guarded, guarded)
// `guarded` settles every carrier and business failure as a
// FeedbackActionResult and never rethrows, so this tail cannot reject and
// needs no rejection handler.
this.operationTail = result.then(() => undefined)
return result
}
/**
* Replace one message's entry, keeping every other entry's identity. Only a
* `mutate` operation reaches this, and `mutate` refuses admission once the
* controller is disposed, so no disposal guard belongs here; `publish` is
* the single place that stops notifying after listeners are dropped.
*/
private commit(messageId: MessageId, item: MessageFeedbackItem | null): void {
const items = new Map(this.view.items)
if (item === null) items.delete(messageId)
else items.set(messageId, item)
this.publish({ status: 'ready', items, error: null })
}
/** Replace the view and contain subscriber failures at the observable boundary. */
private publish(view: FeedbackView): void {
this.view = Object.freeze(view)
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.error('[ui-feedback] subscriber threw:', error)
}
}
}
}
@@ -0,0 +1,84 @@
/**
* Message feedback plugin, browser half: the Like/Dislike entry in the
* conversation.chat.assistant-actions strip. One FeedbackController per
* Session backs every message control in that Session, so a single list read
* seeds the whole transcript. Mutations go through the generated
* messageFeedback Remote; the Host owns per-item compare-and-set.
* @module @deepseek-ai/dsh-client-ui-feedback/client
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { FeedbackController } from './controller.ts'
import { FeedbackActions } from './FeedbackActions.tsx'
import type { FeedbackInjected } from './slots.ts'
import { en, zh } from './locales.ts'
export type {
FeedbackActionResult, FeedbackStatus, FeedbackView, MessageFeedbackRemote,
} from './controller.ts'
export type { FeedbackActionProps, FeedbackInjected } from './slots.ts'
export type { FeedbackKey } from './locales.ts'
/** Dictionary namespace owned by this plugin. */
const NS = 'feedback'
/** Required services: the slot registry, the Remote namespace, and the copy. */
export const inject = ['slots', 'remote', 'remote.messageFeedback', 'locale']
/**
* Client plugin body: the per-message feedback entry and its per-session
* object layer.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-feedback: dictionaries')
const controllers = new Map<SessionId, FeedbackController>()
const controllerFor = (sessionId: SessionId): FeedbackController => {
let controller = controllers.get(sessionId)
if (controller === undefined) {
controller = new FeedbackController(ctx.remote.messageFeedback, sessionId)
controllers.set(sessionId, controller)
}
return controller
}
// A reconnect can only invalidate what was already read; a cold Session
// stays cold until something asks for it.
ctx.on('connection/reset', () => {
for (const controller of controllers.values()) {
if (controller.getSnapshot().status !== 'cold') void controller.resync()
}
})
ctx.slots.inject('conversation.chat.assistant-actions', () => {
const dispose = ctx.slots.register({
name: 'conversation.chat.assistant-actions',
id: 'feedback',
order: 10,
locale: NS,
inject: (sessionId): FeedbackInjected => {
const controller = controllerFor(sessionId)
return {
hooks: { feedback: controller },
ensure: () => controller.ensure(),
rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
toggle: (messageId, rating) => controller.toggle(messageId, rating),
clearNote: messageId => controller.clearNote(messageId),
clear: messageId => controller.clear(messageId),
}
},
}, FeedbackActions)
return () => {
dispose()
for (const controller of controllers.values()) controller.dispose()
controllers.clear()
}
})
}
@@ -0,0 +1,43 @@
/** `feedback` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'action.like': '好的回答',
'action.likeActive': '取消标记',
'action.dislike': '有问题的回答',
'action.dislikeActive': '取消标记',
'note.open': '补充说明',
'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
'note.save': '保存',
'note.cancel': '取消',
'note.aria': '反馈说明',
'error.conflict': '这条反馈已在别处改动,已显示最新状态',
'error.load': '反馈状态加载失败',
'error.generic': '反馈保存失败',
} satisfies Record<string, string>
/** The feedback namespace key union. */
export type FeedbackKey = keyof typeof zh
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The per-message feedback controls' copy. */
feedback: FeedbackKey
}
}
/** English dictionary, checked complete against the zh key set. */
export const en = {
'action.like': 'Good response',
'action.likeActive': 'Remove rating',
'action.dislike': 'Bad response',
'action.dislikeActive': 'Remove rating',
'note.open': 'Add a note',
'note.placeholder': 'What was good, or what went wrong? (optional)',
'note.save': 'Save',
'note.cancel': 'Cancel',
'note.aria': 'Feedback note',
'error.conflict': 'This feedback changed elsewhere; the latest state is shown',
'error.load': 'Could not load feedback',
'error.generic': 'Could not save feedback',
} satisfies Record<FeedbackKey, string>
@@ -0,0 +1,64 @@
/**
* The feedback entry's injected face. The target
* 'conversation.chat.assistant-actions' slot is declared and typed by
* ui-conversation; this package only contributes the entry, so no SlotMap
* merge lives here. Live per-message state arrives through the `feedback`
* hook (the framework standard kit binds it into `useFeedback`); inject
* carries the two mutation verbs plus the lazy loader.
* @module @deepseek-ai/dsh-client-ui-feedback/client/slots
*/
import type {
HostObservable, InjectFace, PropsLocale, PropsRuntime,
} from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
// Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat).
import type {} from './locales.ts'
import type { FeedbackActionResult, FeedbackView } from './controller.ts'
/** Injected business face of one assistant-message feedback entry. */
export interface FeedbackInjected {
hooks: {
/** The owning Session's feedback view, shared by every message control. */
feedback: HostObservable<FeedbackView>
}
/** Load the Session's feedback once, on first interaction. */
ensure: () => Promise<FeedbackActionResult>
/**
* Create or replace this Session's feedback for one message.
* @param messageId - target assistant message.
* @param rating - desired judgment.
* @param note - optional explanation.
*/
rate: (
messageId: MessageId,
rating: MessageFeedbackRating,
note?: string,
) => Promise<FeedbackActionResult>
/**
* Apply the requested judgment, retracting instead when the committed rating
* already matches. The controller decides from the committed item, so a click
* before the first list read still toggles the stored value.
* @param messageId - target assistant message.
* @param rating - the judgment the human asked for.
*/
toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<FeedbackActionResult>
/**
* Drop the note while keeping the rating.
* @param messageId - target assistant message.
*/
clearNote: (messageId: MessageId) => Promise<FeedbackActionResult>
/**
* Remove this Session's feedback for one message.
* @param messageId - target assistant message.
*/
clear: (messageId: MessageId) => Promise<FeedbackActionResult>
}
/** Full props of one assistant-message feedback entry. */
export type FeedbackActionProps =
PropsRuntime<'conversation.chat.assistant-actions'>
& InjectFace<FeedbackInjected>
& PropsLocale<'feedback'>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
+9
View File
@@ -0,0 +1,9 @@
/**
* Message feedback surface plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dsh.client declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */
export function apply(): void {}
@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-feedback`.
* @module @deepseek-ai/dsh-client-ui-feedback/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-feedback'
/** Cordis companion plugin name. */
export const name = 'client-ui-feedback-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the plugin owns one slot registration and one
* per-session controller map, both released by the same effect disposer. The
* lifecycle spec proves the registration is withdrawn and every controller is
* dropped when the owning fiber is disposed, so no second authority exists to
* check at runtime.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,214 @@
// @vitest-environment jsdom
/**
* ui-feedback browser half on a real cordis Context with fake slots/remote
* faces: the plugin registers the feedback entry at
* conversation.chat.assistant-actions, one controller per Session backs every
* message in that Session, a reconnect refreshes only Sessions that were
* already read, and registration plus controller disposal ride the plugin
* fiber (HMR safety). The node half and the invariant companion are exercised
* over the same Context.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup } from '@testing-library/react'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
import type { FeedbackInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
afterEach(cleanup)
const sid = (k: string): SessionId => k as SessionId
const MSG = 'm-1' as MessageId
const seeded: MessageFeedbackItem = {
messageId: MSG,
rating: 'positive',
version: 'v1' as MessageFeedbackVersion,
createdAt: 1,
updatedAt: 1,
}
/** Boot the plugin over fake faces; the Remote namespace records every call. */
async function bench() {
const ctx = new Context()
const calls: { method: string; request: unknown }[] = []
// The generated face wraps every business result in the carrier envelope.
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
const messageFeedback = {
list: (request: unknown) => {
calls.push({ method: 'list', request })
return carried({ ok: true as const, value: { items: [seeded] } })
},
put: (request: unknown) => {
calls.push({ method: 'put', request })
return carried({ ok: true as const, value: seeded })
},
delete: (request: unknown) => {
calls.push({ method: 'delete', request })
return carried({ ok: true as const, value: { absent: true as const } })
},
}
class RemoteService extends Service {
constructor(serviceCtx: Context) {
super(serviceCtx, 'remote')
}
}
new RemoteService(ctx)
ctx.provide('remote.messageFeedback', messageFeedback)
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root',
children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
return {
ctx,
fiber,
calls,
entry: () => {
const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
if (entry === undefined) return undefined
return {
...entry.options,
locale: entry.locale,
inject: entry.inject as unknown as ((sessionId: SessionId) => FeedbackInjected) | undefined,
}
},
}
}
describe('ui-feedback browser plugin', () => {
it('registers the feedback entry with the documented id, order, and locale', async () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
expect(b.entry()?.inject).toBeTypeOf('function')
})
it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
expect(face.ensure).toBeTypeOf('function')
expect(face.rate).toBeTypeOf('function')
expect(face.clear).toBeTypeOf('function')
})
it('shares one controller across every message in the same Session', async () => {
const b = await bench()
await b.fiber.await()
const first = b.entry()!.inject!(sid('s1'))
const second = b.entry()!.inject!(sid('s1'))
expect(first.hooks.feedback).toBe(second.hooks.feedback)
await first.ensure()
await second.ensure()
expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('keeps separate Sessions on separate controllers', async () => {
const b = await bench()
await b.fiber.await()
const one = b.entry()!.inject!(sid('s1'))
const two = b.entry()!.inject!(sid('s2'))
expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
await one.ensure()
await two.ensure()
expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
{ sessionId: 's1' },
{ sessionId: 's2' },
])
})
it('routes rate and clear to the Remote with the addressed message', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
expect(await face.clear(MSG)).toEqual({ ok: true })
expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
})
expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
sessionId: 's1', messageId: MSG,
})
})
it('routes toggle and clearNote to the controller', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
expect(await face.clearNote(MSG)).toEqual({ ok: true })
// The seeded item is positive with no note, so a negative toggle replaces it
// through put, and clearNote has nothing to drop and touches no wire.
const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
expect(puts).toHaveLength(1)
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
})
it('refreshes only Sessions already read when the connection resets', async () => {
const b = await bench()
await b.fiber.await()
const warm = b.entry()!.inject!(sid('warm'))
await warm.ensure()
b.entry()!.inject!(sid('cold'))
const before = b.calls.filter(call => call.method === 'list').length
b.ctx.emit('connection/reset')
await Promise.resolve()
const reads = b.calls.filter(call => call.method === 'list')
expect(reads).toHaveLength(before + 1)
expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
})
it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
await face.ensure()
await b.fiber.dispose()
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
// A disposed controller refuses further mutations, so no request outlives the fiber.
const before = b.calls.length
expect(await face.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(b.calls).toHaveLength(before)
})
it('re-registers cleanly when the plugin is reloaded', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
const reloaded = b.ctx.plugin({ inject: [...inject], apply })
await reloaded.await()
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
expect(b.entry()).toMatchObject({ id: 'feedback' })
})
it('the node half applies without host-side behavior', () => {
// The invariant companion is mounted by the vitest-wide invariant host on
// every Context this suite creates; its registration is covered there.
expect(() => { nodeApply() }).not.toThrow()
})
})

Some files were not shown because too many files have changed in this diff Show More