Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2702

This commit is contained in:
_Kerman
2026-08-21 11:53:00 +08:00
40 changed files with 313 additions and 131 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-15-llm-model-catalog-and-acp-selection.md
2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b93b333c6f25a779b1d307b6141bea9f5eb1e407
2026-07-15-llm-model-catalog-and-acp-selection.md: 8a7b882c3c6b6e6153a2d3b26d5c56440cb09658
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 77c27bf1f2148ecae7ab8857572bf58fbc3086a9
@@ -24,7 +24,7 @@ ACP selection must also preserve the provider dimension. The same model id may a
Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input.
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash`, `deepseek-v4-pro` named `DeepSeek-V4-Pro`, and image-capable `deepseek-v4-flash-vision-exp` named `DeepSeek-V4-Flash-Vision-Exp`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
### Per-session selection in the front end
@@ -24,7 +24,7 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。
`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash``deepseek-v4-flash`名为 `DeepSeek-V4-Pro``deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash``deepseek-v4-flash`名为 `DeepSeek-V4-Pro``deepseek-v4-pro`,以及名为 `DeepSeek-V4-Flash-Vision-Exp`、支持图片输入的 `deepseek-v4-flash-vision-exp`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
### 前端内的会话级选择
@@ -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-22-web-multimodal-image-input-and-durable-attachments.md
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fc9dee1d767d2de4cbb5170499a10cf3a231c333
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 3f77ab8d55f8eca821cd12a4591c6239c2ea10f5
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: c95c5abe664635f3cde3a1fc2d569c9474c69665
@@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast.
Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`.
Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route advertises `deepseek-v4-flash-vision-exp` as image-capable and accepts configured image-capable catalog entries; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`.
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
@@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.zh.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。
Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。
Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由会将 `deepseek-v4-flash-vision-exp` 公布为支持图片,并接受已配置且支持图片的 catalog 配置项;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
@@ -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-19-direct-deepseek-vision-input.md
2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04
2026-08-19-direct-deepseek-vision-input.zh.md: 3e0fc26ee8ba8d0bed1478d54add5be1bfef01df
2026-08-19-direct-deepseek-vision-input.md: 76d3244e67a73c1cdf4419a6537ada38e0a75bd5
2026-08-19-direct-deepseek-vision-input.zh.md: a77231104156371fe698f8a8ad386cfa03251990
@@ -10,7 +10,7 @@ DeepSeek vision deployments use the chat-completions image protocol, but the dir
## Decision
The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently.
The shipped catalog declares `deepseek-v4-flash-vision-exp` with `inputModalities: [text, image]`; configured catalogs use the same declaration to opt another exact model into image input, and validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only.
The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O.
@@ -27,8 +27,8 @@ Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist
## Verification
Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder.
Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A real-API smoke test with an explicit image-capable catalog entry sends a deterministic image only when `DEEPSEEK_VISION_E2E=1` is set in addition to the provider key.
## Consequences
Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available.
The official DeepSeek vision route and configured vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available.
@@ -10,7 +10,7 @@ DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-
## Decision
直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。
随附目录为 `deepseek-v4-flash-vision-exp` 声明 `inputModalities: [text, image]`已配置目录可以用同一声明让另一个确切模型支持图片输入,校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。
适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。
@@ -27,8 +27,8 @@ DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-
## Verification
包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。
包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。真实 API 冒烟测试会配置明确支持图片的目录项,并且仅在提供方密钥之外还设置 `DEEPSEEK_VISION_E2E=1` 时发送确定性图片。
## Consequences
已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。
官方 DeepSeek 视觉路由与已配置视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。
@@ -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-06-16-pnpm-over-yarn.md
2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1
2026-06-16-pnpm-over-yarn.zh.md: c2c289ad2e43b6bdcb0997e9a4e18421d6705c6a
2026-06-16-pnpm-over-yarn.md: c42616f8d1dcacd925719bc8662683f04022573b
2026-06-16-pnpm-over-yarn.zh.md: df2adbb3873415e2c732f4eb5f876f17e3c86003
@@ -12,11 +12,12 @@ The switching cost is at its lowest right now. Nothing publishes from this repo
## Decision
Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed through Corepack (same mechanism Yarn used):
Adopt **pnpm 11.7.0**, pinned via the `packageManager` field. Contributor setup uses Corepack, while CI installs that pin through `pnpm/action-setup`:
- **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs).
- **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist.
- **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript.
- **Shell-free package-manager re-entry.** Repository scripts that start another pnpm command resolve `npm_execpath` by file form: `.js`, `.cjs`, and `.mjs` entries run under the current Node executable, while native and shebang executables run directly. Neither path uses a shell, so command paths and arguments retain their literal contents across platforms. The [native Windows pull-request job](2026-08-08-native-windows-pull-request-ci.md) provisions `@pnpm/exe`, so its complete inventory supplies a real PE-entry integration signal.
- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope.
- All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock``pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy.
@@ -25,6 +26,8 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu
- **Keep Yarn 4** — zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager.
- **npm workspaces** — ubiquitous, but no constraints story and weaker monorepo ergonomics.
- **pnpm with the hoisted linker** — smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move.
- **Always run `npm_execpath` through Node** — works for pnpm's JavaScript distribution but asks Node to parse the ELF, Mach-O, or PE executable supplied by `@pnpm/exe`.
- **Run re-entry commands through a shell** — accepts more launcher forms but changes quoting, metacharacter expansion, executable resolution, and signal behavior for every child command.
## Consequences
@@ -12,11 +12,12 @@ Status: implemented
## 决策
采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同)
采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本。贡献者环境使用 CorepackCI 则通过 `pnpm/action-setup` 安装该固定版本
- **Workspaces** 从 `package.json``workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml``vendor/*``packages/*`——同样的 glob`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。
- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.zh.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。
- **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild``lefthook``@google/genai``protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。
- **无 shell 的包管理器再进入。** 需要启动另一条 pnpm 命令的仓库脚本按文件形式解析 `npm_execpath``.js``.cjs``.mjs` 入口由当前 Node 可执行文件运行,原生及带 shebang 的可执行入口则直接运行。两条路径都不使用 shell,因此命令路径和参数在各平台上均保留字面内容。[原生 Windows 拉取请求作业](2026-08-08-native-windows-pull-request-ci.zh.md)会提供 `@pnpm/exe`,因此其完整清单会产生真实的 PE 入口集成信号。
- **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true``@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`vendor 包仅检查是否为私有。
- 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …``yarn.lock``pnpm-lock.yaml`lockfile v9)。`.gitignore``.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。
@@ -25,6 +26,8 @@ Status: implemented
- **保留 Yarn 4**——零变动,但押注于使用率较低的链接器模式和一个绑定单一包管理器的约束引擎。
- **npm workspaces**——无处不在,但没有约束方案,monorepo 开发体验也较差。
- **pnpm 搭配提升式链接器**——迁移更平滑,但放弃了幻影依赖安全性,而这正是迁移的核心正确性理由。
- **始终通过 Node 运行 `npm_execpath`**——适用于 pnpm 的 JavaScript 分发,但会让 Node 尝试解析 `@pnpm/exe` 提供的 ELF、Mach-O 或 PE 可执行文件。
- **通过 shell 运行再进入命令**——可接受更多启动器形式,但会改变每条子命令的引号、元字符展开、可执行文件解析和信号行为。
## 后果
@@ -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-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10
2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312
2026-08-08-native-windows-pull-request-ci.md: 1f8bf7c9e5249ce218fd0d169ed82008c2dbbd36
2026-08-08-native-windows-pull-request-ci.zh.md: efe044e601aebc92f5d9446a1a683c935dcd783b
@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict.
@@ -14,7 +14,7 @@ Status: implemented
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。
+2
View File
@@ -437,6 +437,8 @@ jobs:
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
# Exercise package-script re-entry through the native pnpm.exe distribution.
standalone: true
- uses: actions/setup-node@v6
with:
@@ -205,13 +205,13 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await settings.getByText('自定义设置').click()
await settings.getByRole('button', { name: /删除模型/ }).first().click()
await settings.getByRole('button', { name: '添加模型' }).click()
const customModelId = settings.getByLabel('模型 ID 2')
const customModelId = settings.getByLabel('模型 ID 3')
await customModelId.fill('private-preview')
await settings.getByLabel('显示名称 2').fill('Private Preview')
await settings.getByLabel('显示名称 3').fill('Private Preview')
// Capacities live behind the row's own disclosure, as in the pi-ai form.
await settings.getByRole('button', { name: '容量 2' }).click()
await settings.getByLabel('上下文窗口 2').fill('131072')
await settings.getByLabel('最大输出 token 数 2').fill('64K')
await settings.getByRole('button', { name: '容量 3' }).click()
await settings.getByLabel('上下文窗口 3').fill('131072')
await settings.getByLabel('最大输出 token 数 3').fill('64K')
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
@@ -220,6 +220,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('id: deepseek-v4-pro')
expect(document).toContain('id: deepseek-v4-flash-vision-exp')
expect(document).toContain('inputModalities:')
expect(document).toContain('- image')
expect(document).toContain('id: private-preview')
expect(document).toContain('name: Private Preview')
expect(document).toContain('contextWindow: 131072')
@@ -236,6 +239,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).waitFor({ timeout: 10_000 })
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
@@ -46,20 +46,30 @@
- img
- textbox "模型 ID 2":
- /placeholder: 模型 ID
- text: private-preview
- text: deepseek-v4-flash-vision-exp
- textbox "显示名称 2":
- /placeholder: 显示名称
- text: Private Preview
- button "容量 2" [expanded]:
- text: DeepSeek-V4-Flash-Vision-Exp
- button "容量 2":
- img
- button "删除模型 2":
- img
- textbox "模型 ID 3":
- /placeholder: 模型 ID
- text: private-preview
- textbox "显示名称 3":
- /placeholder: 显示名称
- text: Private Preview
- button "容量 3" [expanded]:
- img
- button "删除模型 3":
- img
- text: 上下文窗口
- textbox "上下文窗口 2":
- textbox "上下文窗口 3":
- /placeholder: 1M
- text: "131072"
- text: 最大输出 token 数
- textbox "最大输出 token 数 2":
- textbox "最大输出 token 数 3":
- /placeholder: 256K
- text: 64K
- button "添加模型":
+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: 8c9fef888a14839678cb923d1ecf1d2740649ff6
config-catalog.zh.md: 4b54e097c6bde92e5a982fd3fdc1aa2a500148b5
config-catalog.md: fa0e4caa36a3754356876b13507530de82ceb37b
config-catalog.zh.md: 8125a8a80de3f82f6135292f0b834fde2867a840
+2 -2
View File
@@ -928,7 +928,7 @@ export interface Config {
maxTokens?: number
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
/** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
@@ -957,7 +957,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts)
<a id="deepseek-aidsh-llm-pi-ai"></a>
+2 -2
View File
@@ -930,7 +930,7 @@ export interface Config {
maxTokens?: number
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
/** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
@@ -959,7 +959,7 @@ export interface DeepSeekCatalogModel {
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts)
<a id="deepseek-aidsh-llm-pi-ai"></a>
@@ -1,7 +1,6 @@
# Code Mode image overlay: mounts the worker runtime and durable attachment
# store so a nested read_image result can cross the generic rich-result bridge.
# The authored snapshot is replay-only; the live config retains the ordinary
# exact provider route for manual use.
# The live config selects the shipped vision route for manual use.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
@@ -11,7 +10,7 @@
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-pro
model: deepseek-v4-flash-vision-exp
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
+2
View File
@@ -14,6 +14,8 @@
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- id: deepseek-v4-flash-vision-exp
inputModalities: [text, image]
# The default composition confines bash AND the filesystem tools to the
# workspace and asks before a wider retry. Snapshot runs select
+3 -5
View File
@@ -1,10 +1,8 @@
# Image-scenario overlay: adds the durable attachment store the read_image tool
# commits through. The store resolves its root from $DSH_HOME, which the
# snapshot harness scopes per run, so the overlay itself carries no paths. The
# app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the
# authored fixture and the pinned header class are flash), because a config
# patch replaces the whole app config. Keyless replay supplies its own exact
# image-capable catalog while the production endpoint remains unavailable.
# app config is restated to select the shipped vision model because a config
# patch replaces the whole app config.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
@@ -14,7 +12,7 @@
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek-official
model: deepseek-v4-flash
model: deepseek-v4-flash-vision-exp
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
+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 packages/llm/llm-deepseek/README.md
README.md: 1c007d34f329177603305da3858c63960e30a898
README.zh.md: 5add3972037cc82fbacf8207476b63e40c7e631e
README.md: bae9011135a9cbc14467086e4b6ebc6f052ed230
README.zh.md: 0a5f0224dbebd62766775822260585825579f4a7
+4 -4
View File
@@ -28,18 +28,18 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
maxDelayMs: 10000
jitterRatio: 0.1
defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default
models: # optional; defaults to V4 Flash and V4 Pro
models: # optional; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp
- id: deepseek-v4-flash
name: DeepSeek-V4-Flash
- id: private-vision
name: Private Vision
- id: deepseek-v4-flash-vision-exp
name: DeepSeek-V4-Flash-Vision-Exp
inputModalities: [text, image]
- id: private-reasoner
description: Company-hosted reasoning model
contextWindow: 512000
```
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Vision models are not advertised by default until their endpoint rollout is complete, but a deployment can add one with `inputModalities: [text, image]`. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only.
The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only.
An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:<media-type>;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message.
+4 -4
View File
@@ -28,18 +28,18 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
maxDelayMs: 10000
jitterRatio: 0.1
defaultContextWindow: 1000000 # optional positive-integer fallback; this is the default
models: # optional; defaults to V4 Flash and V4 Pro
models: # optional; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp
- id: deepseek-v4-flash
name: DeepSeek-V4-Flash
- id: private-vision
name: Private Vision
- id: deepseek-v4-flash-vision-exp
name: DeepSeek-V4-Flash-Vision-Exp
inputModalities: [text, image]
- id: private-reasoner
description: Company-hosted reasoning model
contextWindow: 512000
```
该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash``deepseek-v4-pro`,两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。在视觉模型端点完成发布前,默认目录不会公布视觉模型,但部署方可以通过 `inputModalities: [text, image]` 主动添加。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACPAgent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`
该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash``deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACPAgent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`
支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:<media-type>;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。
+7 -1
View File
@@ -51,6 +51,12 @@ const PROVIDER = 'deepseek-official'
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW },
{
id: 'deepseek-v4-flash-vision-exp',
name: 'DeepSeek-V4-Flash-Vision-Exp',
contextWindow: DEFAULT_CONTEXT_WINDOW,
inputModalities: ['text', 'image'],
},
]
const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
@@ -76,7 +82,7 @@ export interface Config {
maxTokens?: number
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
/** Advisory models shown by discovery consumers; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
+67 -2
View File
@@ -1,10 +1,18 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHash } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
SaveImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
@@ -12,12 +20,49 @@ import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the direct-fetch adapter: V4 Flash + V4 Pro across
* thinking modes and all official effort levels. Key-gated skips
* entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts).
* thinking modes and all official effort levels. The suite skips entirely
* without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally
* requires $DEEPSEEK_VISION_E2E=1 (see vitest.e2e.config.ts).
*/
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const VISION = 'deepseek-v4-flash-vision-exp'
const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1'
const RED_IMAGE = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
'base64',
)
const RED_IMAGE_REF: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`),
mediaType: 'image/png',
bytes: RED_IMAGE.byteLength,
width: 1,
height: 1,
}
class E2eAttachmentStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1024,
maxImagePixels: 1,
maxImageDimension: 1,
mediaTypes: ['image/png'],
}
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.resolve()
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return Promise.resolve(RED_IMAGE_REF)
}
readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise<StoredImageAttachment> {
return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE })
}
}
const contexts: Context[] = []
let identityHome: string
@@ -30,6 +75,7 @@ async function harness(_model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(E2eAttachmentStore)
await ctx.plugin(LlmDeepSeek, config)
return ctx
}
@@ -65,6 +111,25 @@ const weatherTool: ToolSchema = {
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it.skipIf(!VISION_E2E_ENABLED)('recognizes a deterministic image with the official vision model', async () => {
const ctx = await harness(VISION, {
thinking: 'disabled',
})
const result = await assemble(ctx, {
model: VISION,
messages: [createUserMessage({
content: [
{ type: 'text', text: 'This image is one solid color. Reply with only its English color name.' },
{ type: 'image', attachment: RED_IMAGE_REF },
],
source: { kind: 'plugin', plugin: 'test' },
})],
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('red')
})
it('serves a real request with the key held only by a credentials-local document', async () => {
const key = process.env.DEEPSEEK_API_KEY
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
@@ -117,10 +117,7 @@ describe('DeepSeekAdapter against a mock server', () => {
return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) })
}),
} as unknown as AttachmentStore
const adapter = adapterOf({
baseURL: server.url,
models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
}, attachments)
const adapter = adapterOf({ baseURL: server.url }, attachments)
await drain(adapter.stream({
provider: 'deepseek-official',
@@ -768,6 +765,7 @@ describe('plugin registration and config', () => {
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
{ provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] },
])
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash'))
.resolves.toMatchObject({
@@ -786,6 +784,15 @@ describe('plugin registration and config', () => {
defaultEffort: ReasoningEffortId('high'),
},
})
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash-vision-exp'))
.resolves.toMatchObject({
provider: 'deepseek-official',
id: 'deepseek-v4-flash-vision-exp',
name: 'DeepSeek-V4-Flash-Vision-Exp',
inputModalities: ['text', 'image'],
context: { contextWindow: 1_000_000 },
defaultMaxTokens: 256_000,
})
})
it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => {
@@ -865,6 +872,7 @@ describe('plugin registration and config', () => {
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
{ provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] },
])
})
@@ -1081,7 +1089,7 @@ describe('plugin registration and config', () => {
// First-boot onboarding: the route registers so models stay discoverable;
// only the request itself needs a key.
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(3)
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the managed credential store.
@@ -1169,7 +1177,7 @@ describe('plugin registration and config', () => {
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
// Direct embedding shares the plugin's one resolve step, so it advertises
// the same default catalog instead of a divergent empty one.
await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(3)
})
it('resolves connection facts and the credential exactly once per stream call', async () => {
@@ -155,7 +155,7 @@ describe('request-level dynamic configuration', () => {
const dir = await home()
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(3)
await ctx.settings.update(NS, {
models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }],
})
@@ -171,10 +171,7 @@ describe('request-level dynamic configuration', () => {
{ kind: 'sse', events: textEvents },
{ kind: 'sse', events: textEvents },
])
const { ctx } = await boot(dir, {
baseURL: server.url,
models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
})
const { ctx } = await boot(dir, { baseURL: server.url })
const messages = [createUserMessage({
content: [
{ type: 'image', attachment: IMAGE_REF },
@@ -226,7 +223,7 @@ describe('request-level dynamic configuration', () => {
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
// schema and fail the explicit resolve step.
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(3)
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
{ provider: 'deepseek-official', id: 'recovered', name: 'recovered', inputModalities: ['text'] },
+3 -5
View File
@@ -11,14 +11,12 @@ import {
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** Run one package script through the package manager that invoked this build. */
function runScript(script: string, environment: NodeJS.ProcessEnv): void {
const packageManager = process.env.npm_execpath
if (packageManager === undefined || packageManager === '') {
throw new Error('build: npm_execpath is unavailable; invoke the build through a package script')
}
const result = spawnSync(process.execPath, [packageManager, 'run', script], {
const invocation = pnpmInvocation(['run', script], environment)
const result = spawnSync(invocation.command, invocation.args, {
cwd: resolve(import.meta.dirname, '..'),
env: environment,
stdio: 'inherit',
+8 -1
View File
@@ -80,7 +80,14 @@ describe('CI workflow', () => {
expect(windowsNative.env).toMatchObject({
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000',
})
const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record<string, unknown> & { run: string } => (
const nativeSteps = windowsNative.steps as unknown[]
const nativePnpmSetup = nativeSteps.find(step => (
isRecord(step)
&& typeof step.uses === 'string'
&& step.uses.startsWith('pnpm/action-setup@')
))
expect(nativePnpmSetup).toMatchObject({ with: { standalone: true } })
const nativeCommandSteps = nativeSteps.filter((step): step is Record<string, unknown> & { run: string } => (
isRecord(step) && typeof step.run === 'string'
))
expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
+30 -5
View File
@@ -28,6 +28,14 @@ async function temporaryRoot(): Promise<string> {
return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
}
function successfulCommandRecorder(commands: CoverageCommand[]) {
return vi.fn(async (command: CoverageCommand) => {
commands.push(command)
await writeBlob(command)
return passed
})
}
describe('coverage partition count', () => {
it.each([
[undefined, undefined],
@@ -77,11 +85,7 @@ describe('coverage partition coordinator', () => {
it('runs every single-worker partition before one merged threshold check', async () => {
const root = await temporaryRoot()
const commands: CoverageCommand[] = []
const runCommand = vi.fn(async (command: CoverageCommand) => {
commands.push(command)
await writeBlob(command)
return passed
})
const runCommand = successfulCommandRecorder(commands)
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 3,
@@ -99,6 +103,8 @@ describe('coverage partition coordinator', () => {
'merged coverage report',
])
for (const [index, command] of commands.slice(0, 3).entries()) {
expect(command.command).toBe(process.execPath)
expect(command.args[0]).toBe('/pnpm.cjs')
expect(command.args).toEqual(expect.arrayContaining([
'--coverage',
'--coverage.reportOnFailure',
@@ -123,6 +129,25 @@ describe('coverage partition coordinator', () => {
})
})
it('runs a native pnpm entrypoint directly', async () => {
const root = await temporaryRoot()
const commands: CoverageCommand[] = []
const runCommand = successfulCommandRecorder(commands)
const coordinator = new CoveragePartitionCoordinator({
root,
partitions: 2,
pnpmEntrypoint: '/tools/pnpm',
runCommand,
})
await expect(coordinator.run()).resolves.toBe(0)
expect(commands).toHaveLength(3)
for (const command of commands) {
expect(command.command).toBe('/tools/pnpm')
expect(command.args[0]).toBe('exec')
}
})
it('merges normal test failures and returns their failed status', async () => {
const root = await temporaryRoot()
const reported = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+28 -25
View File
@@ -2,6 +2,7 @@
import { spawn } from 'node:child_process'
import { lstat, mkdir, readdir, rm, unlink } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** Environment variable selecting the number of instrumented coverage processes. */
export const COVERAGE_PARTITIONS_ENV = 'DSH_COVERAGE_PARTITIONS'
@@ -16,7 +17,9 @@ export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
export interface CoverageCommand {
/** Diagnostic identity. */
label: string
/** Node arguments; the first argument is pnpm's JavaScript entrypoint. */
/** Executable launched without a platform shell. */
command: string
/** Arguments passed to the executable. */
args: string[]
/** Environment additions for the child. */
env: Record<string, string | undefined>
@@ -47,7 +50,7 @@ export interface CoveragePartitionCoordinatorOptions {
root: string
/** Number of concurrent single-worker Vitest processes. */
partitions: number
/** pnpm JavaScript entrypoint from `npm_execpath`. */
/** pnpm JavaScript or executable entrypoint from `npm_execpath`. */
pnpmEntrypoint: string
/** Additional arguments shared by every partition. */
vitestArgs?: string[]
@@ -142,23 +145,23 @@ export class CoveragePartitionCoordinator {
private partitionCommand(index: number): CoverageCommand {
const blobPath = join(this.blobsRoot, `partition-${index}.json`)
const reportsDirectory = join(this.temporaryRoot, `coverage-${index}`)
const invocation = pnpmInvocation([
'exec',
'vitest',
'run',
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index}/${this.partitions}`,
'--reporter=default',
'--reporter=blob',
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
], { npm_execpath: this.pnpmEntrypoint })
return {
label: `partition ${index}/${this.partitions}`,
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
'run',
'--coverage',
'--coverage.reportOnFailure',
'--maxWorkers=1',
`--shard=${index}/${this.partitions}`,
'--reporter=default',
'--reporter=blob',
`--outputFile.blob=${this.relativePath(blobPath)}`,
`--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,
...this.vitestArgs,
],
...invocation,
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: '1',
@@ -169,15 +172,15 @@ export class CoveragePartitionCoordinator {
}
private mergeCommand(): CoverageCommand {
const invocation = pnpmInvocation([
'exec',
'vitest',
`--merge-reports=${this.relativePath(this.blobsRoot)}`,
'--coverage',
], { npm_execpath: this.pnpmEntrypoint })
return {
label: 'merged coverage report',
args: [
this.pnpmEntrypoint,
'exec',
'vitest',
`--merge-reports=${this.relativePath(this.blobsRoot)}`,
'--coverage',
],
...invocation,
env: {
[COVERAGE_PARTITIONS_ENV]: undefined,
[COVERAGE_PARTITION_MODE_ENV]: undefined,
@@ -213,7 +216,7 @@ function runCoverageCommand(command: CoverageCommand): Promise<CoverageCommandRe
if (value === undefined) Reflect.deleteProperty(env, name)
else env[name] = value
}
const child = spawn(process.execPath, command.args, {
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { pnpmInvocation } from './pnpm-invocation.ts'
describe('pnpm invocation', () => {
it.each([
'/tools/pnpm.js',
'/tools/pnpm.cjs',
'/tools/pnpm.mjs',
'/tools/PNPM.CJS',
'/tools/with spaces/工具/$pnpm;.mjs',
])('runs the JavaScript entrypoint %j through Node', (entrypoint) => {
expect(pnpmInvocation(['run', 'build'], { npm_execpath: entrypoint })).toEqual({
command: process.execPath,
args: [entrypoint, 'run', 'build'],
})
})
it.each([
'/tools/pnpm',
'/tools/with spaces/$pnpm;',
String.raw`C:\Program Files\工具\$pnpm;\pnpm.exe`,
])('runs the executable entrypoint %j directly', (entrypoint) => {
expect(pnpmInvocation(['run', 'build'], { npm_execpath: entrypoint })).toEqual({
command: entrypoint,
args: ['run', 'build'],
})
})
it.each([undefined, ''])('rejects an unavailable lifecycle entrypoint', (entrypoint) => {
expect(() => pnpmInvocation([], { npm_execpath: entrypoint }))
.toThrow('npm_execpath is unavailable; invoke the script through pnpm run')
})
})
+21
View File
@@ -0,0 +1,21 @@
/** Resolve shell-free child-process invocations for the pnpm process that launched a package script. */
/**
* Resolve pnpm's executable and arguments from its lifecycle environment.
* @param args - Arguments to pass to pnpm.
* @param environment - Lifecycle environment containing `npm_execpath`.
* @returns A command and argument array suitable for `spawn` or `spawnSync` without a shell.
*/
export function pnpmInvocation(
args: readonly string[],
environment: NodeJS.ProcessEnv = process.env,
): { command: string; args: string[] } {
const entrypoint = environment.npm_execpath
if (entrypoint === undefined || entrypoint === '') {
throw new Error('pnpm invocation: npm_execpath is unavailable; invoke the script through pnpm run.')
}
if (/\.[cm]?js$/iu.test(entrypoint)) {
return { command: process.execPath, args: [entrypoint, ...args] }
}
return { command: entrypoint, args: [...args] }
}
+12 -2
View File
@@ -31,9 +31,9 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate
}
}
function withPnpmEntrypoint<T>(action: () => T): T {
function withPnpmEntrypoint<T>(action: () => T, entrypoint = '/private/pnpm.cjs'): T {
const previous = process.env.npm_execpath
process.env.npm_execpath = '/private/pnpm.cjs'
process.env.npm_execpath = entrypoint
try {
return action()
} finally {
@@ -83,6 +83,16 @@ describe('gate graph validation', () => {
expect(ids).toContain('public-repository-links')
})
it('launches a native pnpm entrypoint directly', () => {
const entrypoint = String.raw`C:\Program Files\pnpm\pnpm.exe`
const subject = withPnpmEntrypoint(() => gatesForMode('ci-windows-blocking')[0], entrypoint)
expect(subject).toMatchObject({
command: entrypoint,
args: ['run', 'build'],
})
})
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps the DSH package license policy in %s',
(mode) => {
+1 -9
View File
@@ -17,6 +17,7 @@ import {
coverageTestTimeoutArgs,
parseCoveragePartitionCount,
} from './coverage-partitions.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
/** A named aggregate exposed by the gate runner. */
export type Mode =
@@ -195,15 +196,6 @@ function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate
}
}
function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
const entrypoint = process.env.npm_execpath
if (entrypoint === undefined || entrypoint === '') {
throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.')
}
// Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free.
return { command: process.execPath, args: [entrypoint, ...args] }
}
/**
* Construct the complete gate list for a named aggregate.
* @param selected - aggregate mode to construct.
+7 -11
View File
@@ -1,5 +1,6 @@
/** Run serial browser owners before one bounded snapshot pool. */
import { spawn } from 'node:child_process'
import { pnpmInvocation } from './pnpm-invocation.ts'
const serialFiles = [
'apps/web/tests/hmr-live.e2e.ts',
@@ -10,20 +11,15 @@ const workers = Number.parseInt(workerRaw ?? '', 10)
if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
throw new Error(`DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
}
const pnpmEntrypoint = process.env.npm_execpath
if (pnpmEntrypoint === undefined || pnpmEntrypoint === '') {
throw new Error('parallel web snapshots must be invoked through a pnpm package script.')
}
const baseArgs = [pnpmEntrypoint, 'exec', 'vitest', 'run', '--config', 'vitest.web.config.ts']
const invocation = pnpmInvocation(['exec', 'vitest', 'run', '--config', 'vitest.web.config.ts'])
let serialStatus = 0
for (const file of serialFiles) {
serialStatus = await run([...baseArgs, file])
serialStatus = await run(invocation.command, [...invocation.args, file])
if (serialStatus !== 0) break
}
if (serialStatus === 0) {
process.exitCode = await run([
...baseArgs,
process.exitCode = await run(invocation.command, [
...invocation.args,
...serialFiles.map(file => `--exclude=${file}`),
'--fileParallelism',
`--maxWorkers=${String(workers)}`,
@@ -32,9 +28,9 @@ if (serialStatus === 0) {
process.exitCode = serialStatus
}
function run(args: string[]): Promise<number> {
function run(command: string, args: string[]): Promise<number> {
return new Promise((resolveRun, reject) => {
const child = spawn(process.execPath, args, { stdio: 'inherit' })
const child = spawn(command, args, { stdio: 'inherit' })
child.once('error', reject)
child.once('exit', (exitCode, signalCode) => {
if (signalCode !== null) {